All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
Clean-arch split: - Application: IRealtimeNotifier (PushToUserAsync, abstraction) - Api: NotificationHub (/hubs/notifications, [Authorize]) + SignalRNotifier impl với IHubContext<NotificationHub>, uses Clients.User(userId) (default provider resolves NameIdentifier="sub") - Infrastructure: NotificationPushInterceptor — SaveChangesInterceptor capture Notification entities state=Added trong SavingChanges, push qua IRealtimeNotifier trong SavedChanges sau khi commit thành công. Zero caller changes — handlers chỉ cần db.Add(Notification). Attached vào ApplicationDbContext cùng với AuditingInterceptor. Auth: - JWT config thêm OnMessageReceived event: read ?access_token= từ query string khi path = /hubs/* (WebSockets không set headers). - SignalRNotifier singleton (stateless, chỉ delegate IHubContext). FE (both apps): - @microsoft/signalr 8.0.7 vào package.json. - lib/realtime.ts: singleton connection với lazy start + automatic reconnect [0,2s,5s,10s,15s] + accessTokenFactory lấy từ localStorage. - NotificationBell: useEffect subscribe 'notification-created' khi isAuthenticated. On push: invalidate query + toast.message. Fallback polling giảm từ 30s → 60s (realtime cover gap). - AuthContext.logout: dynamic import stopConnection() — avoid leaking auth'd socket across users. Result: ERP-grade feel. Contract transition → Drafter nhận toast ngay trong vòng 100-300ms (same-origin WebSocket), không cần F5 hay polling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Bell, CheckCheck } from 'lucide-react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { toast } from 'sonner'
|
|
import { api } from '@/lib/api'
|
|
import { ensureConnection } from '@/lib/realtime'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import { cn } from '@/lib/cn'
|
|
|
|
type NotificationDto = {
|
|
id: string
|
|
type: number
|
|
title: string
|
|
description: string | null
|
|
href: string | null
|
|
refId: string | null
|
|
createdAt: string
|
|
readAt: string | null
|
|
}
|
|
|
|
const fmtWhen = (iso: string) => {
|
|
const diff = Date.now() - new Date(iso).getTime()
|
|
const mins = Math.floor(diff / 60_000)
|
|
if (mins < 1) return 'vừa xong'
|
|
if (mins < 60) return `${mins}p trước`
|
|
const hrs = Math.floor(mins / 60)
|
|
if (hrs < 24) return `${hrs}h trước`
|
|
const days = Math.floor(hrs / 24)
|
|
return `${days}d trước`
|
|
}
|
|
|
|
export function NotificationBell() {
|
|
const [open, setOpen] = useState(false)
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
const navigate = useNavigate()
|
|
const qc = useQueryClient()
|
|
const { isAuthenticated } = useAuth()
|
|
|
|
const list = useQuery({
|
|
queryKey: ['notifications'],
|
|
queryFn: async () => (await api.get<NotificationDto[]>('/notifications', { params: { limit: 20 } })).data,
|
|
// Fallback polling at 60s in case SignalR disconnects + reconnect fails
|
|
refetchInterval: 60_000,
|
|
})
|
|
|
|
// Subscribe realtime when authenticated. Toast on push + invalidate query.
|
|
useEffect(() => {
|
|
if (!isAuthenticated) return
|
|
let conn: Awaited<ReturnType<typeof ensureConnection>> | null = null
|
|
let cancelled = false
|
|
|
|
const handler = (payload: NotificationDto) => {
|
|
qc.invalidateQueries({ queryKey: ['notifications'] })
|
|
toast.message(payload.title, { description: payload.description ?? undefined })
|
|
}
|
|
|
|
ensureConnection()
|
|
.then(c => {
|
|
if (cancelled) return
|
|
conn = c
|
|
c.on('notification-created', handler)
|
|
})
|
|
.catch(() => {
|
|
// SignalR unavailable — rely on polling fallback
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
if (conn) conn.off('notification-created', handler)
|
|
}
|
|
}, [isAuthenticated, qc])
|
|
|
|
const items = list.data ?? []
|
|
const unread = items.filter(n => !n.readAt).length
|
|
|
|
const markRead = useMutation({
|
|
mutationFn: async (id: string) => api.post(`/notifications/${id}/read`),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
|
})
|
|
|
|
const markAllRead = useMutation({
|
|
mutationFn: async () => api.post('/notifications/read-all'),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const close = (e: MouseEvent) => {
|
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', close)
|
|
return () => document.removeEventListener('mousedown', close)
|
|
}, [open])
|
|
|
|
return (
|
|
<div className="relative" ref={panelRef}>
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="relative flex h-9 w-9 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 hover:text-slate-900"
|
|
aria-label="Thông báo"
|
|
>
|
|
<Bell className="h-4 w-4" />
|
|
{unread > 0 && (
|
|
<span className="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white">
|
|
{unread > 99 ? '99+' : unread}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
|
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
|
|
<div>
|
|
<div className="text-sm font-semibold text-slate-700">Thông báo</div>
|
|
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} chưa đọc` : 'Đã đọc hết'}</div>
|
|
</div>
|
|
{unread > 0 && (
|
|
<button
|
|
onClick={() => markAllRead.mutate()}
|
|
disabled={markAllRead.isPending}
|
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-brand-600 transition hover:bg-brand-50"
|
|
title="Đánh dấu tất cả đã đọc"
|
|
>
|
|
<CheckCheck className="h-3.5 w-3.5" />
|
|
Đọc hết
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{items.length === 0 && (
|
|
<div className="px-4 py-10 text-center text-xs text-slate-400">Không có thông báo nào.</div>
|
|
)}
|
|
{items.map(n => (
|
|
<button
|
|
key={n.id}
|
|
onClick={() => {
|
|
if (!n.readAt) markRead.mutate(n.id)
|
|
if (n.href) navigate(n.href)
|
|
setOpen(false)
|
|
}}
|
|
className={cn(
|
|
'block w-full border-b border-slate-50 px-4 py-3 text-left transition hover:bg-slate-50',
|
|
!n.readAt && 'bg-brand-50/30',
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
|
{n.description && <div className="truncate text-xs text-slate-400">{n.description}</div>}
|
|
</div>
|
|
<div className="shrink-0 text-[10px] text-slate-400">{fmtWhen(n.createdAt)}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|