[CLAUDE] FE: TopBar + NotificationBell + UserMenu — ERP shell foundation
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
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:
@ -1,8 +1,9 @@
|
|||||||
import { Link, NavLink, Outlet } from 'react-router-dom'
|
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 * as Icons from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { TopBar } from '@/components/TopBar'
|
||||||
import type { MenuNode } from '@/types/menu'
|
import type { MenuNode } from '@/types/menu'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ function MenuLeaf({ node }: { node: MenuNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, menu, logout } = useAuth()
|
const { menu } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
@ -87,23 +88,13 @@ export function Layout() {
|
|||||||
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
||||||
{menu.map(n => (n.children.length > 0 ? <MenuGroup key={n.key} node={n} /> : <MenuLeaf key={n.key} node={n} />))}
|
{menu.map(n => (n.children.length > 0 ? <MenuGroup key={n.key} node={n} /> : <MenuLeaf key={n.key} node={n} />))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-slate-200 p-3">
|
|
||||||
<div className="mb-2 px-3 text-xs text-slate-500">
|
|
||||||
<div className="truncate font-medium text-slate-700">{user?.fullName}</div>
|
|
||||||
<div className="truncate">{user?.email}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-slate-600 transition hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Đăng xuất
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main className="flex-1 overflow-auto">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<TopBar />
|
||||||
</main>
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
122
fe-admin/src/components/NotificationBell.tsx
Normal file
122
fe-admin/src/components/NotificationBell.tsx
Normal 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 HĐ đ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ộ HĐ →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
fe-admin/src/components/TopBar.tsx
Normal file
79
fe-admin/src/components/TopBar.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-brand-100 text-xs font-bold text-brand-700">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<span className="hidden max-w-32 truncate text-slate-700 md:inline">{user?.fullName ?? user?.email}</span>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', open && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 z-50 mt-2 w-60 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||||
|
<div className="border-b border-slate-100 px-4 py-3">
|
||||||
|
<div className="truncate text-sm font-medium text-slate-800">{user?.fullName}</div>
|
||||||
|
<div className="truncate text-xs text-slate-500">{user?.email}</div>
|
||||||
|
{user && user.roles.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{user.roles.map(r => (
|
||||||
|
<span key={r} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ title }: { title?: string }) {
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6">
|
||||||
|
<div className="text-sm font-medium text-slate-500">{title}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NotificationBell />
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,15 +1,8 @@
|
|||||||
import { Link, NavLink, Outlet } from 'react-router-dom'
|
import { Link, NavLink, Outlet } from 'react-router-dom'
|
||||||
import { LogOut, Circle, Inbox, FileText, Plus, type LucideIcon } from 'lucide-react'
|
import { Inbox, FileText, Plus } from 'lucide-react'
|
||||||
import * as Icons from 'lucide-react'
|
import { TopBar } from '@/components/TopBar'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
function getIcon(name: string | null): LucideIcon {
|
|
||||||
if (!name) return Circle
|
|
||||||
const cand = (Icons as unknown as Record<string, LucideIcon>)[name]
|
|
||||||
return cand ?? Circle
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản)
|
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản)
|
||||||
const USER_MENU = [
|
const USER_MENU = [
|
||||||
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox },
|
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox },
|
||||||
@ -18,8 +11,6 @@ const USER_MENU = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, logout } = useAuth()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
|
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
|
||||||
@ -30,7 +21,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-1 p-3">
|
<nav className="flex-1 space-y-1 p-3">
|
||||||
{USER_MENU.map(item => {
|
{USER_MENU.map(item => {
|
||||||
const Icon = item.icon ?? getIcon(null)
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@ -48,26 +39,13 @@ export function Layout() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-slate-200 p-3">
|
|
||||||
<div className="mb-2 px-3 text-xs text-slate-500">
|
|
||||||
<div className="truncate font-medium text-slate-700">{user?.fullName}</div>
|
|
||||||
<div className="truncate">{user?.email}</div>
|
|
||||||
{user && user.roles.length > 0 && (
|
|
||||||
<div className="mt-1 font-mono text-[10px]">{user.roles.join(', ')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-slate-600 transition hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Đăng xuất
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main className="flex-1 overflow-auto">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<TopBar />
|
||||||
</main>
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
118
fe-user/src/components/NotificationBell.tsx
Normal file
118
fe-user/src/components/NotificationBell.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type Notification = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
href: string
|
||||||
|
createdAt: string
|
||||||
|
read: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVP: derive "notifications" from inbox (items awaiting action). Future iteration
|
||||||
|
// will replace with a dedicated endpoint fed by domain events + SignalR.
|
||||||
|
function useDerivedNotifications() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['notifications-derived'],
|
||||||
|
queryFn: async (): Promise<Notification[]> => {
|
||||||
|
const { data } = await api.get<ContractListItem[]>('/contracts/inbox')
|
||||||
|
return data.map(c => {
|
||||||
|
const deadline = c.slaDeadline ? new Date(c.slaDeadline).getTime() : null
|
||||||
|
const overdue = deadline !== null && deadline < Date.now()
|
||||||
|
const dueSoon = deadline !== null && !overdue && deadline - Date.now() < 86_400_000
|
||||||
|
const prefix = overdue ? 'Quá hạn SLA — ' : dueSoon ? 'Sắp quá hạn — ' : ''
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
title: `${prefix}${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 = useDerivedNotifications()
|
||||||
|
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">Thông báo</div>
|
||||||
|
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} mục chưa đọc` : 'Không có mục 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">Không có thông báo nào.</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-brand-50/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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('/inbox')
|
||||||
|
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ộ hộp thư →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
fe-user/src/components/TopBar.tsx
Normal file
79
fe-user/src/components/TopBar.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-brand-100 text-xs font-bold text-brand-700">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<span className="hidden max-w-32 truncate text-slate-700 md:inline">{user?.fullName ?? user?.email}</span>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', open && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 z-50 mt-2 w-60 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||||
|
<div className="border-b border-slate-100 px-4 py-3">
|
||||||
|
<div className="truncate text-sm font-medium text-slate-800">{user?.fullName}</div>
|
||||||
|
<div className="truncate text-xs text-slate-500">{user?.email}</div>
|
||||||
|
{user && user.roles.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{user.roles.map(r => (
|
||||||
|
<span key={r} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ title }: { title?: string }) {
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6">
|
||||||
|
<div className="text-sm font-medium text-slate-500">{title}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NotificationBell />
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user