[CLAUDE] FE: TopBar + NotificationBell + UserMenu — ERP shell foundation
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

Kiến trúc Layout giờ tách thành [sidebar] [topbar + content], foundation
để scale thêm module trong tương lai (HR, Accounting, Inventory...).

TopBar: title placeholder + NotificationBell + UserMenu (initials avatar
+ role badges + logout). UserMenu thay cho bottom-of-sidebar (cleaner).

NotificationBell:
- fe-admin: cảnh báo SLA (HĐ quá/sắp quá hạn, 24h window)
- fe-user: hộp thư chờ xử lý (items trong /contracts/inbox)
- refetchInterval: 60s
- Placeholder cho SignalR/email notifications thật sẽ thay bằng
  /api/notifications endpoint ở Tier 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 15:16:15 +07:00
parent 2e43799046
commit 2b6f91c2b2
6 changed files with 416 additions and 49 deletions

View File

@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { Bell } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import type { ContractListItem } from '@/types/contracts'
import type { Paged } from '@/types/master'
type Notification = {
id: string
title: string
description: string
href: string
createdAt: string
read: boolean
}
// MVP: surface overdue-SLA contracts as notifications. Future iteration will
// replace with a dedicated notifications endpoint fed by domain events + SignalR.
function useOverdueNotifications() {
return useQuery({
queryKey: ['notifications-overdue'],
queryFn: async (): Promise<Notification[]> => {
const { data } = await api.get<Paged<ContractListItem>>('/contracts', {
params: { page: 1, pageSize: 50 },
})
const now = Date.now()
return data.items
.filter(c => c.slaDeadline && new Date(c.slaDeadline).getTime() < now + 24 * 60 * 60 * 1000)
.map(c => {
const deadline = c.slaDeadline ? new Date(c.slaDeadline).getTime() : null
const overdue = deadline !== null && deadline < now
return {
id: c.id,
title: `${overdue ? 'Quá hạn SLA' : 'Sắp quá hạn'}${c.maHopDong ?? c.tenHopDong ?? 'HĐ chưa có mã'}`,
description: `Phase ${c.phase} · NCC ${c.supplierName}`,
href: `/contracts/${c.id}`,
createdAt: c.createdAt,
read: false,
}
})
},
refetchInterval: 60_000,
})
}
export function NotificationBell() {
const [open, setOpen] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const q = useOverdueNotifications()
const items = q.data ?? []
const unread = items.filter(n => !n.read).length
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="border-b border-slate-100 px-4 py-2.5">
<div className="text-sm font-semibold text-slate-700">Cảnh báo SLA</div>
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} HĐ cần chú ý` : 'Không có cảnh báo mới'}</div>
</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">Mọi đang đúng tiến đ.</div>
)}
{items.map(n => (
<button
key={n.id}
onClick={() => {
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.read && 'bg-amber-50/40',
)}
>
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
<div className="truncate text-xs text-slate-400">{n.description}</div>
</button>
))}
</div>
{items.length > 0 && (
<button
onClick={() => {
navigate('/contracts')
setOpen(false)
}}
className="block w-full border-t border-slate-100 px-4 py-2 text-center text-xs font-medium text-brand-600 hover:bg-slate-50"
>
Xem toàn bộ
</button>
)}
</div>
)}
</div>
)
}