|
1 | | -import { Link, usePage } from '@inertiajs/react'; |
2 | 1 | import { useEffect, useRef, useState } from 'react'; |
3 | | -import type { PageProps } from '@/types'; |
| 2 | +import axios from 'axios'; |
| 3 | +import { useAuth } from '@/Hooks/useAuth'; |
| 4 | +import { useEchoPrivateChannel } from '@/Hooks/useEchoChannel'; |
4 | 5 |
|
5 | | -interface Notification { |
| 6 | +interface ErpNotification { |
| 7 | + id: number; |
6 | 8 | type: string; |
7 | | - label: string; |
8 | | - href: string; |
9 | | - count: number; |
10 | | - severity: 'warning' | 'info' | 'error'; |
| 9 | + title: string; |
| 10 | + message: string; |
| 11 | + read_at: string | null; |
| 12 | + created_at: string; |
11 | 13 | } |
12 | 14 |
|
13 | | -const SEVERITY_CLASSES: Record<Notification['severity'], string> = { |
14 | | - warning: 'border-l-amber-400 bg-amber-50 text-amber-800', |
15 | | - info: 'border-l-blue-400 bg-blue-50 text-blue-800', |
16 | | - error: 'border-l-red-400 bg-red-50 text-red-800', |
17 | | -}; |
18 | | - |
19 | | -const BADGE_CLASSES: Record<Notification['severity'], string> = { |
20 | | - warning: 'bg-amber-200 text-amber-800', |
21 | | - info: 'bg-blue-200 text-blue-800', |
22 | | - error: 'bg-red-200 text-red-800', |
23 | | -}; |
24 | | - |
25 | 15 | export function NotificationBell() { |
26 | | - const { notifications = [] } = usePage<PageProps & { notifications: Notification[] }>().props; |
27 | | - const total = notifications.reduce((s: number, n: Notification) => s + n.count, 0); |
| 16 | + const { user } = useAuth(); |
| 17 | + const [notifications, setNotifications] = useState<ErpNotification[]>([]); |
| 18 | + const [unread, setUnread] = useState(0); |
28 | 19 | const [open, setOpen] = useState(false); |
29 | 20 | const ref = useRef<HTMLDivElement>(null); |
30 | 21 |
|
| 22 | + // Fetch unread count on mount |
| 23 | + useEffect(() => { |
| 24 | + axios |
| 25 | + .get('/api/v1/notifications/unread-count') |
| 26 | + .then(res => { |
| 27 | + setUnread(res.data.data.count ?? 0); |
| 28 | + }) |
| 29 | + .catch(() => {}); |
| 30 | + }, []); |
| 31 | + |
| 32 | + // Fetch notifications when panel opens |
| 33 | + useEffect(() => { |
| 34 | + if (open) { |
| 35 | + axios |
| 36 | + .get('/api/v1/notifications') |
| 37 | + .then(res => { |
| 38 | + setNotifications(res.data.data ?? []); |
| 39 | + }) |
| 40 | + .catch(() => {}); |
| 41 | + } |
| 42 | + }, [open]); |
| 43 | + |
| 44 | + // Close on outside click |
31 | 45 | useEffect(() => { |
32 | 46 | function handleClickOutside(e: MouseEvent) { |
33 | 47 | if (ref.current && !ref.current.contains(e.target as Node)) { |
34 | 48 | setOpen(false); |
35 | 49 | } |
36 | 50 | } |
37 | 51 | document.addEventListener('mousedown', handleClickOutside); |
38 | | - return () => document.removeEventListener('mousedown', handleClickOutside); |
| 52 | + return () => |
| 53 | + document.removeEventListener('mousedown', handleClickOutside); |
39 | 54 | }, []); |
40 | 55 |
|
| 56 | + // Real-time push via WebSocket |
| 57 | + useEchoPrivateChannel( |
| 58 | + user?.tenant_id ? `tenant.${user.tenant_id}` : null, |
| 59 | + '.ErpNotification', |
| 60 | + () => { |
| 61 | + setUnread(prev => prev + 1); |
| 62 | + } |
| 63 | + ); |
| 64 | + |
| 65 | + async function markAllRead() { |
| 66 | + await axios.post('/api/v1/notifications/mark-all-read'); |
| 67 | + setUnread(0); |
| 68 | + setNotifications(prev => |
| 69 | + prev.map(n => ({ ...n, read_at: new Date().toISOString() })) |
| 70 | + ); |
| 71 | + } |
| 72 | + |
| 73 | + async function markRead(id: number) { |
| 74 | + await axios.post(`/api/v1/notifications/${id}/read`); |
| 75 | + setNotifications(prev => |
| 76 | + prev.map(n => |
| 77 | + n.id === id ? { ...n, read_at: new Date().toISOString() } : n |
| 78 | + ) |
| 79 | + ); |
| 80 | + setUnread(prev => Math.max(0, prev - 1)); |
| 81 | + } |
| 82 | + |
41 | 83 | return ( |
42 | 84 | <div ref={ref} className="relative"> |
43 | 85 | <button |
44 | | - onClick={() => setOpen((o) => !o)} |
| 86 | + onClick={() => setOpen(o => !o)} |
45 | 87 | aria-label="Notifications" |
46 | 88 | className="relative flex h-9 w-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-400" |
47 | 89 | > |
48 | | - <svg className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24"> |
49 | | - <path strokeLinecap="round" strokeLinejoin="round" |
50 | | - d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" /> |
| 90 | + <svg |
| 91 | + className="h-5 w-5" |
| 92 | + fill="none" |
| 93 | + viewBox="0 0 24 24" |
| 94 | + strokeWidth={1.5} |
| 95 | + stroke="currentColor" |
| 96 | + > |
| 97 | + <path |
| 98 | + strokeLinecap="round" |
| 99 | + strokeLinejoin="round" |
| 100 | + d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" |
| 101 | + /> |
51 | 102 | </svg> |
52 | | - {total > 0 && ( |
53 | | - <span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"> |
54 | | - {total > 9 ? '9+' : total} |
| 103 | + {unread > 0 && ( |
| 104 | + <span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"> |
| 105 | + {unread > 9 ? '9+' : unread} |
55 | 106 | </span> |
56 | 107 | )} |
57 | 108 | </button> |
58 | 109 |
|
59 | 110 | {open && ( |
60 | | - <div className="absolute right-0 top-10 z-50 w-80 rounded-xl border border-slate-200 bg-white shadow-lg"> |
61 | | - <div className="flex items-center justify-between px-4 py-3 border-b border-slate-100"> |
62 | | - <span className="text-sm font-semibold text-slate-700">Notifications</span> |
63 | | - {total > 0 && ( |
64 | | - <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"> |
65 | | - {total} |
66 | | - </span> |
| 111 | + <div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-slate-200 bg-white shadow-xl"> |
| 112 | + <div className="flex items-center justify-between border-b border-slate-200 px-4 py-3"> |
| 113 | + <h3 className="text-sm font-semibold text-slate-800"> |
| 114 | + Notifications |
| 115 | + </h3> |
| 116 | + {unread > 0 && ( |
| 117 | + <button |
| 118 | + onClick={markAllRead} |
| 119 | + className="text-xs text-blue-600 hover:underline" |
| 120 | + > |
| 121 | + Mark all read |
| 122 | + </button> |
67 | 123 | )} |
68 | 124 | </div> |
69 | | - |
70 | | - {notifications.length === 0 ? ( |
71 | | - <div className="px-4 py-6 text-center text-sm text-slate-400"> |
72 | | - No notifications |
73 | | - </div> |
74 | | - ) : ( |
75 | | - <ul className="divide-y divide-slate-50 max-h-72 overflow-y-auto"> |
76 | | - {notifications.map((n: Notification) => ( |
77 | | - <li key={n.type}> |
78 | | - <Link |
79 | | - href={n.href} |
80 | | - onClick={() => setOpen(false)} |
81 | | - className={`flex items-center gap-3 px-4 py-3 text-sm hover:opacity-90 border-l-4 ${SEVERITY_CLASSES[n.severity]}`} |
82 | | - > |
83 | | - <span className="flex-1">{n.label}</span> |
84 | | - <span className={`rounded-full px-1.5 py-0.5 text-xs font-bold ${BADGE_CLASSES[n.severity]}`}> |
85 | | - {n.count} |
86 | | - </span> |
87 | | - </Link> |
88 | | - </li> |
89 | | - ))} |
90 | | - </ul> |
91 | | - )} |
| 125 | + <div className="max-h-96 overflow-y-auto"> |
| 126 | + {notifications.length === 0 && ( |
| 127 | + <p className="px-4 py-6 text-center text-sm text-slate-400"> |
| 128 | + No notifications |
| 129 | + </p> |
| 130 | + )} |
| 131 | + {notifications.map(n => ( |
| 132 | + <div |
| 133 | + key={n.id} |
| 134 | + onClick={() => !n.read_at && markRead(n.id)} |
| 135 | + className={`flex cursor-pointer gap-3 border-b border-slate-100 px-4 py-3 last:border-0 hover:bg-slate-50 ${ |
| 136 | + !n.read_at ? 'bg-blue-50' : '' |
| 137 | + }`} |
| 138 | + > |
| 139 | + <div |
| 140 | + className={`mt-1 h-2 w-2 shrink-0 rounded-full ${ |
| 141 | + !n.read_at |
| 142 | + ? 'bg-blue-500' |
| 143 | + : 'bg-transparent' |
| 144 | + }`} |
| 145 | + /> |
| 146 | + <div className="min-w-0 flex-1"> |
| 147 | + <p className="text-sm font-medium text-slate-800"> |
| 148 | + {n.title} |
| 149 | + </p> |
| 150 | + <p className="mt-0.5 line-clamp-2 text-xs text-slate-500"> |
| 151 | + {n.message} |
| 152 | + </p> |
| 153 | + </div> |
| 154 | + </div> |
| 155 | + ))} |
| 156 | + </div> |
92 | 157 | </div> |
93 | 158 | )} |
94 | 159 | </div> |
95 | 160 | ); |
96 | 161 | } |
| 162 | + |
| 163 | +export default NotificationBell; |
0 commit comments