- Implement AdminDomainOrderTest to cover admin functionalities for domain orders including listing, confirming registration, KYC updates, activation, rejection, and refunds. - Implement ClientDomainOrderTest to cover client functionalities for placing domain orders, including validation for balance, duplicate orders, and domain normalization. - Ensure proper handling of domain pricing and service statuses throughout the tests.
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
import { Fragment, useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
|
import dayjs from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import 'dayjs/locale/vi'
|
|
import {
|
|
BellOutlined,
|
|
CheckOutlined,
|
|
ClockCircleOutlined,
|
|
GlobalOutlined,
|
|
InboxOutlined,
|
|
LogoutOutlined,
|
|
MoonOutlined,
|
|
QuestionCircleOutlined,
|
|
SearchOutlined,
|
|
SettingOutlined,
|
|
ShoppingCartOutlined,
|
|
TeamOutlined,
|
|
UserOutlined,
|
|
} from '@ant-design/icons'
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
Breadcrumb,
|
|
Button,
|
|
ConfigProvider,
|
|
Divider,
|
|
Dropdown,
|
|
Flex,
|
|
Input,
|
|
List,
|
|
Popover,
|
|
Select,
|
|
Space,
|
|
Switch,
|
|
Tag,
|
|
Tooltip,
|
|
Typography,
|
|
} from 'antd'
|
|
import { useAuthStore } from '@/stores/auth-store'
|
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
|
import {
|
|
fetchNotifications,
|
|
markAllNotificationsRead,
|
|
markNotificationRead,
|
|
type NotificationItem,
|
|
} from '@/features/notifications/api'
|
|
|
|
dayjs.extend(relativeTime)
|
|
dayjs.locale('vi')
|
|
|
|
const { Text } = Typography
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Breadcrumb labels (mirrors src/components/layout/app-breadcrumb.tsx)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const segmentLabels: Record<string, string> = {
|
|
'': 'Tổng quan',
|
|
dashboard: 'Tổng quan',
|
|
create: 'Thêm mới',
|
|
groups: 'Nhóm',
|
|
tasks: 'Công việc',
|
|
customers: 'Khách hàng',
|
|
'product-service': 'Sản phẩm / Dịch vụ',
|
|
products: 'Sản phẩm',
|
|
'product-groups': 'Nhóm sản phẩm',
|
|
'vcenter-manager': 'vCenter Manager',
|
|
'infra-management': 'Hạ tầng',
|
|
'order-invoice': 'Đơn hàng / Hoá đơn',
|
|
apps: 'Ứng dụng',
|
|
chats: 'Tin nhắn',
|
|
users: 'Người dùng',
|
|
settings: 'Cài đặt',
|
|
account: 'Tài khoản',
|
|
appearance: 'Giao diện',
|
|
notifications: 'Thông báo',
|
|
display: 'Hiển thị',
|
|
'help-center': 'Trung tâm trợ giúp',
|
|
tickets: 'Ticket hỗ trợ',
|
|
roles: 'Vai trò',
|
|
'activity-logs': 'Nhật ký hoạt động',
|
|
'server-manager': 'Server Manager',
|
|
'domain-manager': 'Domain Manager',
|
|
'domain-fluctuations': 'Khai báo biến động tên miền',
|
|
'ip-pool': 'IP Pool',
|
|
'os-groups': 'OS Groups',
|
|
addons: 'Addons',
|
|
transactions: 'Giao dịch',
|
|
posts: 'Bài viết',
|
|
'post-categories': 'Danh mục bài viết',
|
|
resolve: 'Phân giải',
|
|
}
|
|
|
|
function labelFor(segment: string) {
|
|
if (/^\d+$/.test(segment)) return 'Chi tiết'
|
|
return segmentLabels[segment] ?? segment
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Notifications
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const notifIconMap: Record<
|
|
NotificationItem['type'],
|
|
{ icon: typeof BellOutlined; color: string }
|
|
> = {
|
|
ticket_reply: { icon: TeamOutlined, color: '#722ed1' },
|
|
deposit_success: { icon: ShoppingCartOutlined, color: '#f16723' },
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// AppHeader
|
|
// ----------------------------------------------------------------------------
|
|
|
|
export function AppHeader() {
|
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
const { auth } = useAuthStore()
|
|
|
|
const [notifOpen, setNotifOpen] = useState(false)
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
const [darkMode, setDarkMode] = useState(false)
|
|
const [compact, setCompact] = useState(false)
|
|
const [language, setLanguage] = useState<'vi' | 'en'>('vi')
|
|
|
|
const qc = useQueryClient()
|
|
const { data: notifData } = useQuery({
|
|
queryKey: ['notifications'],
|
|
queryFn: fetchNotifications,
|
|
refetchInterval: 60_000,
|
|
})
|
|
const notifs = notifData?.data.items ?? []
|
|
const unreadCount = notifData?.data.unread_count ?? 0
|
|
|
|
const markOneRead = useMutation({
|
|
mutationFn: markNotificationRead,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
|
})
|
|
const markAllRead = useMutation({
|
|
mutationFn: markAllNotificationsRead,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
|
})
|
|
|
|
const segments = pathname.split('/').filter(Boolean)
|
|
const crumbs =
|
|
segments.length === 0
|
|
? [{ title: <Text strong>Tổng quan</Text> }]
|
|
: segments.map((seg, idx) => {
|
|
const isLast = idx === segments.length - 1
|
|
const label = labelFor(seg)
|
|
return {
|
|
title: isLast ? <Text strong>{label}</Text> : <span>{label}</span>,
|
|
}
|
|
})
|
|
|
|
const fullName = auth.user
|
|
? `${auth.user.firstname ?? ''} ${auth.user.lastname ?? ''}`.trim() ||
|
|
auth.user.username
|
|
: 'Khách'
|
|
const userRole = auth.user?.roles?.[0] ?? 'Người dùng'
|
|
const initials =
|
|
fullName
|
|
.split(' ')
|
|
.filter(Boolean)
|
|
.slice(-2)
|
|
.map((s) => s[0]?.toUpperCase())
|
|
.join('') || 'U'
|
|
|
|
const navigate = useNavigate()
|
|
|
|
const userMenuItems = [
|
|
{
|
|
key: 'profile',
|
|
icon: <UserOutlined />,
|
|
label: 'Hồ sơ',
|
|
onClick: () => navigate({ to: '/settings' }),
|
|
},
|
|
{
|
|
key: 'logout',
|
|
icon: <LogoutOutlined />,
|
|
label: 'Đăng xuất',
|
|
danger: true,
|
|
onClick: () => auth.reset(),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<ConfigProvider
|
|
theme={{
|
|
token: {
|
|
colorPrimary: '#f16723',
|
|
borderRadius: 8,
|
|
fontFamily:
|
|
'"Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
},
|
|
}}
|
|
>
|
|
<header className='sticky top-0 z-50 flex h-16 items-center gap-2 border-b border-[#f0f0f0] bg-white px-3 md:gap-4 md:px-6'>
|
|
<SidebarTrigger />
|
|
<Divider
|
|
type='vertical'
|
|
className='!hidden md:!block'
|
|
style={{ height: 24 }}
|
|
/>
|
|
<div className='hidden min-w-0 flex-1 md:block'>
|
|
<Breadcrumb items={crumbs} />
|
|
</div>
|
|
<div className='flex-1 md:hidden' />
|
|
<Input
|
|
allowClear
|
|
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
|
placeholder='Tìm kiếm...'
|
|
className='!hidden lg:!inline-flex'
|
|
style={{ width: 280 }}
|
|
/>
|
|
{/* Mobile/Tablet: search as icon button only */}
|
|
<Tooltip title='Tìm kiếm' mouseEnterDelay={0.4}>
|
|
<Button
|
|
type='text'
|
|
icon={<SearchOutlined />}
|
|
className='lg:!hidden'
|
|
/>
|
|
</Tooltip>
|
|
|
|
{/* Notifications */}
|
|
<Popover
|
|
trigger='click'
|
|
placement='bottomRight'
|
|
open={notifOpen}
|
|
onOpenChange={setNotifOpen}
|
|
overlayStyle={{ width: 'min(380px, calc(100vw - 24px))' }}
|
|
content={
|
|
<div>
|
|
<Flex
|
|
justify='space-between'
|
|
align='center'
|
|
style={{ marginBottom: 8 }}
|
|
>
|
|
<Space>
|
|
<Text strong>Thông báo</Text>
|
|
{unreadCount > 0 && (
|
|
<Tag color='blue' style={{ margin: 0 }}>
|
|
{unreadCount} mới
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
<Space size={4}>
|
|
<Button
|
|
type='text'
|
|
icon={<CheckOutlined />}
|
|
disabled={unreadCount === 0}
|
|
loading={markAllRead.isPending}
|
|
onClick={() => markAllRead.mutate()}
|
|
>
|
|
Đọc hết
|
|
</Button>
|
|
</Space>
|
|
</Flex>
|
|
<Divider style={{ margin: '4px 0 8px' }} />
|
|
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
|
{notifs.length === 0 ? (
|
|
<div
|
|
style={{
|
|
padding: '32px 0',
|
|
textAlign: 'center',
|
|
color: '#8c8c8c',
|
|
}}
|
|
>
|
|
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
|
<div style={{ marginTop: 8, fontSize: 13 }}>
|
|
Chưa có thông báo
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<List
|
|
dataSource={notifs}
|
|
split={false}
|
|
renderItem={(n: NotificationItem) => {
|
|
const meta = notifIconMap[n.type] ?? { icon: BellOutlined, color: '#8c8c8c' }
|
|
const Icon = meta.icon
|
|
const unread = !n.is_read
|
|
return (
|
|
<List.Item
|
|
onClick={() => { if (unread) markOneRead.mutate(n.id) }}
|
|
style={{
|
|
padding: '10px 8px',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
background: unread ? '#f0f7ff' : 'transparent',
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
<Flex gap={10} style={{ width: '100%' }}>
|
|
<div
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 8,
|
|
background: `${meta.color}1A`,
|
|
color: meta.color,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Icon />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<Flex justify='space-between' align='center'>
|
|
<Text
|
|
strong={unread}
|
|
style={{ fontSize: 13 }}
|
|
ellipsis
|
|
>
|
|
{n.title}
|
|
</Text>
|
|
{unread && (
|
|
<span
|
|
style={{
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 4,
|
|
background: '#f16723',
|
|
flexShrink: 0,
|
|
marginLeft: 6,
|
|
}}
|
|
/>
|
|
)}
|
|
</Flex>
|
|
{n.body && (
|
|
<Text
|
|
type='secondary'
|
|
style={{
|
|
fontSize: 12,
|
|
display: 'block',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
{n.body}
|
|
</Text>
|
|
)}
|
|
<Text type='secondary' style={{ fontSize: 11 }}>
|
|
<ClockCircleOutlined /> {dayjs(n.created_at).fromNow()}
|
|
</Text>
|
|
</div>
|
|
</Flex>
|
|
</List.Item>
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<Divider style={{ margin: '8px 0' }} />
|
|
<Button block type='link' onClick={() => setNotifOpen(false)}>
|
|
Xem tất cả thông báo
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<Tooltip title='Thông báo' mouseEnterDelay={0.4}>
|
|
<Badge count={unreadCount}>
|
|
<Button type='text' icon={<BellOutlined />} />
|
|
</Badge>
|
|
</Tooltip>
|
|
</Popover>
|
|
|
|
{/* Settings */}
|
|
<Popover
|
|
trigger='click'
|
|
placement='bottomRight'
|
|
open={settingsOpen}
|
|
onOpenChange={setSettingsOpen}
|
|
overlayStyle={{ width: 'min(280px, calc(100vw - 24px))' }}
|
|
content={
|
|
<div>
|
|
<Text strong>Cài đặt nhanh</Text>
|
|
<Divider style={{ margin: '8px 0' }} />
|
|
<Flex
|
|
justify='space-between'
|
|
align='center'
|
|
style={{ padding: '6px 0' }}
|
|
>
|
|
<Space>
|
|
<MoonOutlined />
|
|
<Text>Chế độ tối</Text>
|
|
</Space>
|
|
<Switch checked={darkMode} onChange={setDarkMode} />
|
|
</Flex>
|
|
<Flex
|
|
justify='space-between'
|
|
align='center'
|
|
style={{ padding: '6px 0' }}
|
|
>
|
|
<Space>
|
|
<InboxOutlined />
|
|
<Text>Giao diện gọn</Text>
|
|
</Space>
|
|
<Switch checked={compact} onChange={setCompact} />
|
|
</Flex>
|
|
<Flex
|
|
justify='space-between'
|
|
align='center'
|
|
style={{ padding: '6px 0' }}
|
|
>
|
|
<Space>
|
|
<GlobalOutlined />
|
|
<Text>Ngôn ngữ</Text>
|
|
</Space>
|
|
<Select
|
|
value={language}
|
|
onChange={setLanguage}
|
|
style={{ width: 110 }}
|
|
options={[
|
|
{ value: 'vi', label: 'Tiếng Việt' },
|
|
{ value: 'en', label: 'English' },
|
|
]}
|
|
/>
|
|
</Flex>
|
|
<Divider style={{ margin: '8px 0' }} />
|
|
<List
|
|
split={false}
|
|
dataSource={[
|
|
{
|
|
icon: <UserOutlined />,
|
|
label: 'Hồ sơ cá nhân',
|
|
key: 'profile',
|
|
},
|
|
{
|
|
icon: <SettingOutlined />,
|
|
label: 'Cài đặt hệ thống',
|
|
key: 'system',
|
|
},
|
|
{
|
|
icon: <QuestionCircleOutlined />,
|
|
label: 'Trợ giúp & hỗ trợ',
|
|
key: 'help',
|
|
},
|
|
{
|
|
icon: <LogoutOutlined />,
|
|
label: 'Đăng xuất',
|
|
key: 'logout',
|
|
danger: true,
|
|
},
|
|
]}
|
|
renderItem={(it) => (
|
|
<List.Item
|
|
onClick={() => {
|
|
setSettingsOpen(false)
|
|
if (it.key === 'logout') auth.reset()
|
|
}}
|
|
style={{
|
|
padding: '8px',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
color: it.danger ? '#ff4d4f' : undefined,
|
|
}}
|
|
>
|
|
<Space>
|
|
{it.icon}
|
|
<Text
|
|
style={{ color: it.danger ? '#ff4d4f' : undefined }}
|
|
>
|
|
{it.label}
|
|
</Text>
|
|
</Space>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</div>
|
|
}
|
|
>
|
|
<Tooltip title='Cài đặt' mouseEnterDelay={0.4}>
|
|
<Button type='text' icon={<SettingOutlined />} />
|
|
</Tooltip>
|
|
</Popover>
|
|
|
|
<Divider
|
|
type='vertical'
|
|
className='!hidden sm:!block'
|
|
style={{ height: 24 }}
|
|
/>
|
|
|
|
<Dropdown
|
|
menu={{ items: userMenuItems }}
|
|
placement='bottomRight'
|
|
arrow
|
|
>
|
|
<Space size={8} className='cursor-pointer'>
|
|
<Avatar style={{ background: '#f16723' }}>{initials}</Avatar>
|
|
<div className='hidden leading-tight sm:block'>
|
|
<div
|
|
style={{ fontWeight: 600, fontSize: 13 }}
|
|
className='max-w-[160px] truncate'
|
|
>
|
|
{fullName}
|
|
</div>
|
|
<Text
|
|
type='secondary'
|
|
style={{ fontSize: 12 }}
|
|
className='block max-w-[160px] truncate'
|
|
>
|
|
{userRole}
|
|
</Text>
|
|
</div>
|
|
</Space>
|
|
</Dropdown>
|
|
</header>
|
|
{/* Keep Fragment children possible for future right-aligned actions */}
|
|
<Fragment />
|
|
</ConfigProvider>
|
|
)
|
|
}
|