Skip to content

Commit c3a09a0

Browse files
committed
feat(phase-23): in-app notifications system
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 3803562 commit c3a09a0

3 files changed

Lines changed: 345 additions & 59 deletions

File tree

Lines changed: 126 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,163 @@
1-
import { Link, usePage } from '@inertiajs/react';
21
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';
45

5-
interface Notification {
6+
interface ErpNotification {
7+
id: number;
68
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;
1113
}
1214

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-
2515
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);
2819
const [open, setOpen] = useState(false);
2920
const ref = useRef<HTMLDivElement>(null);
3021

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
3145
useEffect(() => {
3246
function handleClickOutside(e: MouseEvent) {
3347
if (ref.current && !ref.current.contains(e.target as Node)) {
3448
setOpen(false);
3549
}
3650
}
3751
document.addEventListener('mousedown', handleClickOutside);
38-
return () => document.removeEventListener('mousedown', handleClickOutside);
52+
return () =>
53+
document.removeEventListener('mousedown', handleClickOutside);
3954
}, []);
4055

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+
4183
return (
4284
<div ref={ref} className="relative">
4385
<button
44-
onClick={() => setOpen((o) => !o)}
86+
onClick={() => setOpen(o => !o)}
4587
aria-label="Notifications"
4688
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"
4789
>
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+
/>
51102
</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}
55106
</span>
56107
)}
57108
</button>
58109

59110
{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>
67123
)}
68124
</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>
92157
</div>
93158
)}
94159
</div>
95160
);
96161
}
162+
163+
export default NotificationBell;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useEffect, useState } from 'react';
2+
import axios from 'axios';
3+
import { useEchoPrivateChannel } from '@/Hooks/useEchoChannel';
4+
5+
interface Notification {
6+
id: number;
7+
type: string;
8+
title: string;
9+
message: string;
10+
read_at: string | null;
11+
created_at: string;
12+
}
13+
14+
interface Props {
15+
tenantId: number;
16+
}
17+
18+
export default function NotificationBell({ tenantId }: Props) {
19+
const [notifications, setNotifications] = useState<Notification[]>([]);
20+
const [unread, setUnread] = useState(0);
21+
const [open, setOpen] = useState(false);
22+
23+
useEffect(() => {
24+
axios.get('/api/v1/notifications/unread-count').then(res => {
25+
setUnread(res.data.data.count);
26+
});
27+
if (open) {
28+
axios.get('/api/v1/notifications').then(res => {
29+
setNotifications(res.data.data ?? []);
30+
});
31+
}
32+
}, [open]);
33+
34+
useEchoPrivateChannel(`tenant.${tenantId}`, '.ErpNotification', () => {
35+
setUnread(prev => prev + 1);
36+
});
37+
38+
async function markAllRead() {
39+
await axios.post('/api/v1/notifications/mark-all-read');
40+
setUnread(0);
41+
setNotifications(prev =>
42+
prev.map(n => ({ ...n, read_at: new Date().toISOString() }))
43+
);
44+
}
45+
46+
async function markRead(id: number) {
47+
await axios.post(`/api/v1/notifications/${id}/read`);
48+
setNotifications(prev =>
49+
prev.map(n =>
50+
n.id === id ? { ...n, read_at: new Date().toISOString() } : n
51+
)
52+
);
53+
setUnread(prev => Math.max(0, prev - 1));
54+
}
55+
56+
return (
57+
<div className="relative">
58+
<button
59+
onClick={() => setOpen(!open)}
60+
className="relative flex h-9 w-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700"
61+
>
62+
<svg
63+
className="h-5 w-5"
64+
fill="none"
65+
viewBox="0 0 24 24"
66+
strokeWidth={1.5}
67+
stroke="currentColor"
68+
>
69+
<path
70+
strokeLinecap="round"
71+
strokeLinejoin="round"
72+
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"
73+
/>
74+
</svg>
75+
{unread > 0 && (
76+
<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">
77+
{unread > 9 ? '9+' : unread}
78+
</span>
79+
)}
80+
</button>
81+
82+
{open && (
83+
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-slate-200 bg-white shadow-xl">
84+
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
85+
<h3 className="text-sm font-semibold text-slate-800">
86+
Notifications
87+
</h3>
88+
{unread > 0 && (
89+
<button
90+
onClick={markAllRead}
91+
className="text-xs text-blue-600 hover:underline"
92+
>
93+
Mark all read
94+
</button>
95+
)}
96+
</div>
97+
<div className="max-h-96 overflow-y-auto">
98+
{notifications.length === 0 && (
99+
<p className="px-4 py-6 text-center text-sm text-slate-400">
100+
No notifications
101+
</p>
102+
)}
103+
{notifications.map(n => (
104+
<div
105+
key={n.id}
106+
onClick={() => !n.read_at && markRead(n.id)}
107+
className={`flex cursor-pointer gap-3 border-b border-slate-100 px-4 py-3 last:border-0 hover:bg-slate-50 ${
108+
!n.read_at ? 'bg-blue-50' : ''
109+
}`}
110+
>
111+
<div
112+
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${
113+
!n.read_at
114+
? 'bg-blue-500'
115+
: 'bg-transparent'
116+
}`}
117+
/>
118+
<div className="min-w-0 flex-1">
119+
<p className="text-sm font-medium text-slate-800">
120+
{n.title}
121+
</p>
122+
<p className="mt-0.5 text-xs text-slate-500 line-clamp-2">
123+
{n.message}
124+
</p>
125+
</div>
126+
</div>
127+
))}
128+
</div>
129+
</div>
130+
)}
131+
</div>
132+
);
133+
}

0 commit comments

Comments
 (0)