diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx
index ebdc048..0576ace 100644
--- a/fe-admin/src/components/Layout.tsx
+++ b/fe-admin/src/components/Layout.tsx
@@ -1,8 +1,9 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
-import { LogOut, ChevronDown, Circle, type LucideIcon } from 'lucide-react'
+import { ChevronDown, Circle, type LucideIcon } from 'lucide-react'
import * as Icons from 'lucide-react'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
+import { TopBar } from '@/components/TopBar'
import type { MenuNode } from '@/types/menu'
import { cn } from '@/lib/cn'
@@ -74,7 +75,7 @@ function MenuLeaf({ node }: { node: MenuNode }) {
}
export function Layout() {
- const { user, menu, logout } = useAuth()
+ const { menu } = useAuth()
return (
@@ -87,23 +88,13 @@ export function Layout() {
-
-
-
{user?.fullName}
-
{user?.email}
-
-
-
-
-
-
+
+
+
+
+
+
)
}
diff --git a/fe-admin/src/components/NotificationBell.tsx b/fe-admin/src/components/NotificationBell.tsx
new file mode 100644
index 0000000..7618c13
--- /dev/null
+++ b/fe-admin/src/components/NotificationBell.tsx
@@ -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 => {
+ const { data } = await api.get>('/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(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 (
+
+
+
+ {open && (
+
+
+
Cảnh báo SLA
+
{unread > 0 ? `${unread} HĐ cần chú ý` : 'Không có cảnh báo mới'}
+
+
+ {items.length === 0 && (
+
Mọi HĐ đang đúng tiến độ.
+ )}
+ {items.map(n => (
+
+ ))}
+
+ {items.length > 0 && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/fe-admin/src/components/TopBar.tsx b/fe-admin/src/components/TopBar.tsx
new file mode 100644
index 0000000..13be051
--- /dev/null
+++ b/fe-admin/src/components/TopBar.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useRef, useState } from 'react'
+import { ChevronDown, LogOut } from 'lucide-react'
+import { useAuth } from '@/contexts/AuthContext'
+import { NotificationBell } from '@/components/NotificationBell'
+import { cn } from '@/lib/cn'
+
+function UserMenu() {
+ const { user, logout } = useAuth()
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const close = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', close)
+ return () => document.removeEventListener('mousedown', close)
+ }, [open])
+
+ const initials = (user?.fullName ?? user?.email ?? '?')
+ .split(' ')
+ .filter(Boolean)
+ .map(s => s[0])
+ .slice(-2)
+ .join('')
+ .toUpperCase()
+
+ return (
+
+
+ {open && (
+
+
+
{user?.fullName}
+
{user?.email}
+ {user && user.roles.length > 0 && (
+
+ {user.roles.map(r => (
+
+ {r}
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ )
+}
+
+export function TopBar({ title }: { title?: string }) {
+ return (
+
+ )
+}
diff --git a/fe-user/src/components/Layout.tsx b/fe-user/src/components/Layout.tsx
index 2ab18c2..3e74c66 100644
--- a/fe-user/src/components/Layout.tsx
+++ b/fe-user/src/components/Layout.tsx
@@ -1,15 +1,8 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
-import { LogOut, Circle, Inbox, FileText, Plus, type LucideIcon } from 'lucide-react'
-import * as Icons from 'lucide-react'
-import { useAuth } from '@/contexts/AuthContext'
+import { Inbox, FileText, Plus } from 'lucide-react'
+import { TopBar } from '@/components/TopBar'
import { cn } from '@/lib/cn'
-function getIcon(name: string | null): LucideIcon {
- if (!name) return Circle
- const cand = (Icons as unknown as Record)[name]
- return cand ?? Circle
-}
-
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản)
const USER_MENU = [
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox },
@@ -18,8 +11,6 @@ const USER_MENU = [
]
export function Layout() {
- const { user, logout } = useAuth()
-
return (