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 ( +
+
{title}
+
+ + +
+
+ ) +} 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 (