Xây Dựng URL Shortener: Giao Diện Admin Dashboard Bằng React

Ba bài trước mình đã build xong toàn bộ backend cho admin: role-based access control, user management endpoints, URL moderation APIs, system analytics aggregation, và audit logging. Curl mọi endpoint ngon lành, JSON response đầy đủ, permissions chạy đúng. Nhưng admin panel mà không ai navigate được thì cũng như không.
APIs mà thiếu UI thì giống như xe hơi không có vô-lăng vậy. Engine chạy ngon nhưng mà lái kiểu gì? Giờ là lúc build admin dashboard frontend — một giao diện thực sự để admin có thể monitor system health, quản lý users, moderate URLs, và review audit trails, tất cả ngay trên browser.
Trong bài này, mình sẽ build toàn bộ admin dashboard như một extension của React frontend đã có từ Phase 8. Mình sẽ thêm admin layout với sidebar navigation, metric cards với trend indicators, interactive charts bằng Recharts, full user management table, URL moderation queue với bulk actions, và audit log viewer với expandable details.
Thời gian thực hiện: 3-4 giờ
Yêu cầu trước: Phase 12: System Analytics & Audit Logs
Những gì chúng ta sẽ build trong bài này:
✅ Admin layout với sidebar navigation và role-based menu
✅ Dashboard overview page với metric cards và trend indicators
✅ Analytics page với interactive Recharts charts (clicks, URLs, users)
✅ User management page với search, filters, và action buttons
✅ URL moderation queue với bulk actions
✅ Audit log viewer với filtering và detail expansion
Kiến Trúc Admin Frontend
Trước khi code, mình cần hình dung tổng thể cái mình sẽ build. Phần admin là một khu vực riêng biệt trong React app hiện tại, có layout, navigation, và pages riêng:
Mọi admin page đều theo cùng pattern: fetch data bằng React Query hooks, display trong responsive layout, và handle mutations với optimistic updates. Giờ mình build từng phần một.
Cài Thêm Dependencies
Mình đã có React Router, Tailwind CSS, và Recharts từ Phase 8 rồi. Chỉ cần thêm vài package nữa cho admin UI:
cd frontend
npm install @tanstack/react-query date-fns
npm install -D @types/react- @tanstack/react-query — Data fetching với caching, refetching, và mutation support
- date-fns — Format ngày tháng gọn nhẹ (không cần moment.js nặng nề)
Route Setup & Protection
Admin pages cần hai lớp bảo vệ: authentication (user phải đăng nhập) và authorization (user phải có admin role). Mình tạo một route guard component:
// src/components/AdminRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function AdminRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN') {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}Giờ gắn vào router hiện tại. Thêm admin routes bên cạnh các user routes đã có:
// src/App.tsx — thêm các routes này vào Router
import { AdminRoute } from './components/AdminRoute';
import { AdminLayout } from './layouts/AdminLayout';
import { AdminDashboard } from './pages/admin/AdminDashboard';
import { AdminAnalytics } from './pages/admin/AdminAnalytics';
import { AdminUsers } from './pages/admin/AdminUsers';
import { AdminUrls } from './pages/admin/AdminUrls';
import { AdminAuditLogs } from './pages/admin/AdminAuditLogs';
// Bên trong route configuration:
<Route
element={
<AdminRoute>
<AdminLayout />
</AdminRoute>
}
>
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/analytics" element={<AdminAnalytics />} />
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/urls" element={<AdminUrls />} />
<Route path="/admin/audit-logs" element={<AdminAuditLogs />} />
</Route>AdminRoute guard wrap toàn bộ layout, nên mọi nested route đều được bảo vệ. Nếu user thường cố vào /admin, họ bị đá về dashboard của mình.
Admin Layout Với Sidebar Navigation
Admin layout là bộ khung mà mọi admin page sống bên trong — sidebar cố định bên trái, content area bên phải, top bar cho mobile. Đây là component quan trọng nhất vì mọi page đều kế thừa nó:
// src/layouts/AdminLayout.tsx
import { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const navItems = [
{
path: '/admin',
label: 'Dashboard',
icon: '📊',
end: true, // exact match
},
{
path: '/admin/analytics',
label: 'Analytics',
icon: '📈',
},
{
path: '/admin/users',
label: 'Users',
icon: '👥',
},
{
path: '/admin/urls',
label: 'URLs',
icon: '🔗',
},
{
path: '/admin/audit-logs',
label: 'Audit Logs',
icon: '📋',
},
];
export function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed inset-y-0 left-0 z-30 w-64 bg-white dark:bg-gray-800
border-r border-gray-200 dark:border-gray-700
transform transition-transform duration-200 ease-in-out
lg:translate-x-0 lg:static lg:inset-auto
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{/* Sidebar header */}
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-700">
<NavLink to="/admin" className="flex items-center space-x-2">
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">
Admin Panel
</span>
</NavLink>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-gray-500 hover:text-gray-700"
>
✕
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.end}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`flex items-center px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`
}
>
<span className="mr-3 text-lg">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
{/* User info ở cuối sidebar */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
{user?.name?.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
{user?.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user?.role}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="mt-3 w-full px-4 py-2 text-sm text-gray-600 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Đăng Xuất
</button>
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top bar (mobile) */}
<header className="lg:hidden flex items-center h-16 px-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setSidebarOpen(true)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-300"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="ml-4 text-lg font-semibold text-gray-800 dark:text-gray-200">
Admin
</span>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}Những điểm chính trong layout này:
- Mobile-responsive — sidebar trượt vào từ bên trái trên mobile với overlay backdrop
- Active link highlighting —
NavLinkvớiisActivehiển thị visual feedback cho page hiện tại - User info — hiện tên và role của admin đang đăng nhập ở cuối sidebar
- Dark mode — mọi element đều có
dark:variants để chuyển theme mượt mà <Outlet />— React Router render child page tương ứng tại đây
Admin API Client & Hooks
Trước khi build pages, mình cần hooks để fetch admin data. Mình sẽ mở rộng API client hiện tại và tạo các hooks chuyên dụng với React Query:
// src/api/adminApi.ts
import { apiClient } from './client';
export interface DashboardMetrics {
totalUsers: number;
totalUrls: number;
totalClicks: number;
flaggedUrls: number;
userChange: number; // phần trăm thay đổi so với hôm qua
urlChange: number;
clickChange: number;
flaggedChange: number;
todayUsers: number;
todayUrls: number;
todayClicks: number;
clicksLast7Days: { date: string; count: number }[];
}
export interface AdminUser {
id: string;
name: string;
email: string;
role: 'USER' | 'ADMIN' | 'SUPER_ADMIN';
status: 'ACTIVE' | 'SUSPENDED';
urlCount: number;
totalClicks: number;
createdAt: string;
lastLoginAt: string | null;
}
export interface AdminUrl {
id: string;
shortCode: string;
originalUrl: string;
userId: string;
userEmail: string;
status: 'ACTIVE' | 'FLAGGED' | 'DISABLED';
clickCount: number;
createdAt: string;
flagReason?: string;
}
export interface AuditLog {
id: string;
adminId: string;
adminName: string;
action: string;
targetType: string;
targetId: string;
details: Record<string, unknown>;
ipAddress: string;
createdAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface UserQueryParams {
page?: number;
pageSize?: number;
search?: string;
role?: string;
status?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface UrlQueryParams {
page?: number;
pageSize?: number;
search?: string;
status?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface AuditQueryParams {
page?: number;
pageSize?: number;
action?: string;
adminId?: string;
startDate?: string;
endDate?: string;
}
export const adminApi = {
// Dashboard
getDashboardMetrics: () =>
apiClient.get<DashboardMetrics>('/admin/dashboard').then((r) => r.data),
// Analytics
getClicksOverTime: (period: '7d' | '30d' | '90d') =>
apiClient.get(`/admin/analytics/clicks?period=${period}`).then((r) => r.data),
getUrlCreationTrends: (period: '7d' | '30d' | '90d') =>
apiClient.get(`/admin/analytics/urls?period=${period}`).then((r) => r.data),
getDeviceBreakdown: () =>
apiClient.get('/admin/analytics/devices').then((r) => r.data),
getUserGrowth: (period: '30d' | '90d' | '1y') =>
apiClient.get(`/admin/analytics/user-growth?period=${period}`).then((r) => r.data),
getTopCountries: () =>
apiClient.get('/admin/analytics/countries').then((r) => r.data),
// Users
getUsers: (params: UserQueryParams) =>
apiClient
.get<PaginatedResponse<AdminUser>>('/admin/users', { params })
.then((r) => r.data),
suspendUser: (userId: string, reason: string) =>
apiClient.post(`/admin/users/${userId}/suspend`, { reason }),
reactivateUser: (userId: string) =>
apiClient.post(`/admin/users/${userId}/reactivate`),
updateUserRole: (userId: string, role: string) =>
apiClient.patch(`/admin/users/${userId}/role`, { role }),
// URLs
getUrls: (params: UrlQueryParams) =>
apiClient
.get<PaginatedResponse<AdminUrl>>('/admin/urls', { params })
.then((r) => r.data),
flagUrl: (urlId: string, reason: string) =>
apiClient.post(`/admin/urls/${urlId}/flag`, { reason }),
disableUrl: (urlId: string) =>
apiClient.post(`/admin/urls/${urlId}/disable`),
reactivateUrl: (urlId: string) =>
apiClient.post(`/admin/urls/${urlId}/reactivate`),
bulkDisableUrls: (urlIds: string[]) =>
apiClient.post('/admin/urls/bulk-disable', { urlIds }),
bulkDeleteUrls: (urlIds: string[]) =>
apiClient.post('/admin/urls/bulk-delete', { urlIds }),
// Audit Logs
getAuditLogs: (params: AuditQueryParams) =>
apiClient
.get<PaginatedResponse<AuditLog>>('/admin/audit-logs', { params })
.then((r) => r.data),
};Giờ tới hooks sử dụng các API calls trên:
// src/hooks/useAdminApi.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi, UserQueryParams, UrlQueryParams, AuditQueryParams } from '../api/adminApi';
// ============================================
// Dashboard
// ============================================
export function useDashboardMetrics() {
return useQuery({
queryKey: ['admin', 'dashboard'],
queryFn: () => adminApi.getDashboardMetrics(),
refetchInterval: 5 * 60 * 1000, // Tự refresh mỗi 5 phút
});
}
// ============================================
// Analytics
// ============================================
export function useClicksOverTime(period: '7d' | '30d' | '90d') {
return useQuery({
queryKey: ['admin', 'analytics', 'clicks', period],
queryFn: () => adminApi.getClicksOverTime(period),
});
}
export function useUrlCreationTrends(period: '7d' | '30d' | '90d') {
return useQuery({
queryKey: ['admin', 'analytics', 'urls', period],
queryFn: () => adminApi.getUrlCreationTrends(period),
});
}
export function useDeviceBreakdown() {
return useQuery({
queryKey: ['admin', 'analytics', 'devices'],
queryFn: () => adminApi.getDeviceBreakdown(),
});
}
export function useUserGrowth(period: '30d' | '90d' | '1y') {
return useQuery({
queryKey: ['admin', 'analytics', 'user-growth', period],
queryFn: () => adminApi.getUserGrowth(period),
});
}
export function useTopCountries() {
return useQuery({
queryKey: ['admin', 'analytics', 'countries'],
queryFn: () => adminApi.getTopCountries(),
});
}
// ============================================
// Users
// ============================================
export function useAdminUsers(params: UserQueryParams) {
return useQuery({
queryKey: ['admin', 'users', params],
queryFn: () => adminApi.getUsers(params),
placeholderData: (previousData) => previousData,
});
}
export function useSuspendUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, reason }: { userId: string; reason: string }) =>
adminApi.suspendUser(userId, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
},
});
}
export function useReactivateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: string) => adminApi.reactivateUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
export function useUpdateUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
adminApi.updateUserRole(userId, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
// ============================================
// URLs
// ============================================
export function useAdminUrls(params: UrlQueryParams) {
return useQuery({
queryKey: ['admin', 'urls', params],
queryFn: () => adminApi.getUrls(params),
placeholderData: (previousData) => previousData,
});
}
export function useFlagUrl() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ urlId, reason }: { urlId: string; reason: string }) =>
adminApi.flagUrl(urlId, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'urls'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
},
});
}
export function useDisableUrl() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (urlId: string) => adminApi.disableUrl(urlId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'urls'] });
},
});
}
export function useBulkDisableUrls() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (urlIds: string[]) => adminApi.bulkDisableUrls(urlIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'urls'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
},
});
}
// ============================================
// Audit Logs
// ============================================
export function useAuditLogs(params: AuditQueryParams) {
return useQuery({
queryKey: ['admin', 'audit-logs', params],
queryFn: () => adminApi.getAuditLogs(params),
placeholderData: (previousData) => previousData,
});
}Những pattern quan trọng cần chú ý:
queryKeyarrays — React Query dùng chúng để cache và deduplicate requests. Bao gồm params trong key nghĩa là filters khác nhau sẽ tạo cache entries khác nhau.invalidateQuerieskhi mutation — sau khi suspend user, mình invalidate cả users list và dashboard metrics để chúng refresh với data mới nhất.placeholderData— giữ data cũ hiển thị trong khi load kết quả mới (không bị flash trắng khi chuyển trang).refetchInterval— dashboard tự refresh mỗi 5 phút để metrics luôn cập nhật.
Dashboard Overview Page
Dashboard là "nhà" của admin — nhìn một cái là nắm được sức khỏe hệ thống. Nó hiển thị bốn metric cards với trend indicators, quick stats hôm nay, và mini sparkline chart của clicks gần đây:
// src/pages/admin/AdminDashboard.tsx
import { useDashboardMetrics } from '../../hooks/useAdminApi';
interface MetricCardProps {
title: string;
value: number;
change: number;
icon: string;
color: 'blue' | 'green' | 'purple' | 'red';
}
const colorMap = {
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
};
function MetricCard({ title, value, change, icon, color }: MetricCardProps) {
const isPositive = change >= 0;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
{title}
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
{value.toLocaleString()}
</p>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center text-2xl ${colorMap[color]}`}>
{icon}
</div>
</div>
<div className="mt-4 flex items-center">
<span
className={`inline-flex items-center text-sm font-medium ${
isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}
>
{isPositive ? '↑' : '↓'} {Math.abs(change)}%
</span>
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
so với hôm qua
</span>
</div>
</div>
);
}
function QuickStats({
todayUsers,
todayUrls,
todayClicks,
}: {
todayUsers: number;
todayUrls: number;
todayClicks: number;
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Hoạt Động Hôm Nay
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{todayUsers}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Users Mới</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{todayUrls}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">URLs Tạo Mới</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{todayClicks}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Tổng Clicks</p>
</div>
</div>
</div>
);
}
function MiniClicksChart({ data }: { data: { date: string; count: number }[] }) {
if (!data || data.length === 0) return null;
const max = Math.max(...data.map((d) => d.count));
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Clicks — 7 Ngày Qua
</h3>
<div className="flex items-end space-x-2 h-32">
{data.map((day) => (
<div key={day.date} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-blue-500 dark:bg-blue-400 rounded-t"
style={{ height: `${max > 0 ? (day.count / max) * 100 : 0}%` }}
/>
<span className="text-xs text-gray-400 mt-1">
{new Date(day.date).toLocaleDateString('vi', { weekday: 'short' })}
</span>
</div>
))}
</div>
</div>
);
}
export function AdminDashboard() {
const { data: metrics, isLoading, error } = useDashboardMetrics();
if (isLoading) {
return <DashboardSkeleton />;
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">
Không thể tải dashboard metrics. Vui lòng thử lại.
</p>
</div>
);
}
if (!metrics) return null;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Tổng quan hệ thống và các chỉ số chính
</p>
</div>
{/* Metric cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Tổng Users"
value={metrics.totalUsers}
change={metrics.userChange}
icon="👥"
color="blue"
/>
<MetricCard
title="Tổng URLs"
value={metrics.totalUrls}
change={metrics.urlChange}
icon="🔗"
color="green"
/>
<MetricCard
title="Tổng Clicks"
value={metrics.totalClicks}
change={metrics.clickChange}
icon="📈"
color="purple"
/>
<MetricCard
title="URLs Bị Flag"
value={metrics.flaggedUrls}
change={metrics.flaggedChange}
icon="⚠️"
color="red"
/>
</div>
{/* Hàng thứ hai */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<QuickStats
todayUsers={metrics.todayUsers}
todayUrls={metrics.todayUrls}
todayClicks={metrics.todayClicks}
/>
<MiniClicksChart data={metrics.clicksLast7Days} />
</div>
</div>
);
}
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-32" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-48" />
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-48" />
</div>
</div>
);
}DashboardSkeleton hiển thị placeholder shapes trong khi data đang load — UX tốt hơn nhiều so với trang trắng hay một spinner quay quay. Mỗi metric card hiển thị giá trị hiện tại cộng thêm phần trăm thay đổi so với hôm qua, giúp admin phát hiện trends ngay lập tức.
Analytics Charts Page
Trang analytics đi sâu hơn dashboard. Ở đây mình dùng Recharts để build interactive, responsive charts cho admin khám phá click patterns, URL creation trends, device distributions, và user growth:
// src/pages/admin/AdminAnalytics.tsx
import { useState } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
useClicksOverTime,
useUrlCreationTrends,
useDeviceBreakdown,
useUserGrowth,
useTopCountries,
} from '../../hooks/useAdminApi';
type TimePeriod = '7d' | '30d' | '90d';
function PeriodToggle({
value,
onChange,
}: {
value: TimePeriod;
onChange: (v: TimePeriod) => void;
}) {
const options: TimePeriod[] = ['7d', '30d', '90d'];
return (
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{options.map((opt) => (
<button
key={opt}
onClick={() => onChange(opt)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
value === opt
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
{opt}
</button>
))}
</div>
);
}
function ChartCard({
title,
children,
actions,
}: {
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
{actions}
</div>
{children}
</div>
);
}
const CHART_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
function ClicksChart() {
const [period, setPeriod] = useState<TimePeriod>('7d');
const { data, isLoading } = useClicksOverTime(period);
return (
<ChartCard
title="Clicks Theo Thời Gian"
actions={<PeriodToggle value={period} onChange={setPeriod} />}
>
{isLoading ? (
<div className="h-72 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(date: string) =>
new Date(date).toLocaleDateString('vi', { month: 'short', day: 'numeric' })
}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
}}
labelFormatter={(date: string) =>
new Date(date).toLocaleDateString('vi', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}
/>
<Legend />
<Line
type="monotone"
dataKey="clicks"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
name="Clicks"
/>
</LineChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}
function UrlCreationChart() {
const [period, setPeriod] = useState<TimePeriod>('30d');
const { data, isLoading } = useUrlCreationTrends(period);
return (
<ChartCard
title="Xu Hướng Tạo URL"
actions={<PeriodToggle value={period} onChange={setPeriod} />}
>
{isLoading ? (
<div className="h-72 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(date: string) =>
new Date(date).toLocaleDateString('vi', { month: 'short', day: 'numeric' })
}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
}}
/>
<Legend />
<Bar dataKey="urls" fill="#10b981" radius={[4, 4, 0, 0]} name="URLs Đã Tạo" />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}
function DeviceBreakdownChart() {
const { data, isLoading } = useDeviceBreakdown();
return (
<ChartCard title="Phân Bổ Thiết Bị">
{isLoading ? (
<div className="h-72 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
dataKey="count"
nameKey="device"
label={({ name, percent }: { name: string; percent: number }) =>
`${name} ${(percent * 100).toFixed(0)}%`
}
>
{data?.map((_entry: unknown, index: number) => (
<Cell key={index} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}
function UserGrowthChart() {
const [period, setPeriod] = useState<'30d' | '90d' | '1y'>('90d');
const { data, isLoading } = useUserGrowth(period);
return (
<ChartCard
title="Tăng Trưởng Users"
actions={
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(['30d', '90d', '1y'] as const).map((opt) => (
<button
key={opt}
onClick={() => setPeriod(opt)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
period === opt
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{opt}
</button>
))}
</div>
}
>
{isLoading ? (
<div className="h-72 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(date: string) =>
new Date(date).toLocaleDateString('vi', { month: 'short' })
}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="totalUsers"
stroke="#8b5cf6"
fill="#8b5cf6"
fillOpacity={0.2}
strokeWidth={2}
name="Tổng Users"
/>
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}
function TopCountriesTable() {
const { data, isLoading } = useTopCountries();
return (
<ChartCard title="Top Quốc Gia">
{isLoading ? (
<div className="space-y-3 animate-pulse">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-8 bg-gray-200 dark:bg-gray-700 rounded" />
))}
</div>
) : (
<div className="space-y-3">
{data?.slice(0, 10).map(
(country: { code: string; name: string; clicks: number }, index: number) => (
<div key={country.code} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-sm text-gray-400 w-6">{index + 1}.</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{country.name}
</span>
</div>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{country.clicks.toLocaleString()}
</span>
</div>
)
)}
</div>
)}
</ChartCard>
);
}
export function AdminAnalytics() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Analytics
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Phân tích hệ thống chi tiết và các xu hướng
</p>
</div>
{/* Charts toàn chiều rộng */}
<ClicksChart />
<UrlCreationChart />
{/* Layout hai cột */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DeviceBreakdownChart />
<TopCountriesTable />
</div>
{/* User growth toàn chiều rộng */}
<UserGrowthChart />
</div>
);
}Vài tips khi dùng Recharts đáng ghi nhớ:
ResponsiveContainerbọc mọi chart để nó fill parent và resize đúngCartesianGridvớistrokeDasharraytạo grid nhẹ nhàng không lấn át data- Custom
tickFormattertrên XAxis chuyển raw dates thành labels dễ đọc dot={false}vớiactiveDottrên LineChart giữ line sạch sẽ nhưng hiện dot khi hover- Donut chart thực ra là PieChart với
innerRadius— gọn gàng hơn pie đầy đủ
User Management Page
Đây là trang nhiều tương tác nhất. Admin cần search users, filter theo role và status, sort columns, và thực hiện các actions như suspend, reactivate, hay change roles. Mình build từng bước:
// src/pages/admin/AdminUsers.tsx
import { useState } from 'react';
import { format } from 'date-fns';
import {
useAdminUsers,
useSuspendUser,
useReactivateUser,
useUpdateUserRole,
} from '../../hooks/useAdminApi';
import { AdminUser } from '../../api/adminApi';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
confirmLabel: string;
confirmVariant?: 'danger' | 'primary';
onConfirm: () => void;
onCancel: () => void;
children?: React.ReactNode;
}
function ConfirmModal({
isOpen,
title,
message,
confirmLabel,
confirmVariant = 'danger',
onConfirm,
onCancel,
children,
}: ConfirmModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{message}
</p>
{children}
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300
bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Hủy
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg ${
confirmVariant === 'danger'
? 'bg-red-600 hover:bg-red-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles =
status === 'ACTIVE'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400';
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles}`}>
{status}
</span>
);
}
function RoleBadge({ role }: { role: string }) {
const styles: Record<string, string> = {
SUPER_ADMIN: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400',
ADMIN: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
USER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[role] || styles.USER}`}>
{role}
</span>
);
}
export function AdminUsers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [sortBy, setSortBy] = useState('createdAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Modal state
const [suspendTarget, setSuspendTarget] = useState<AdminUser | null>(null);
const [suspendReason, setSuspendReason] = useState('');
const [roleChangeTarget, setRoleChangeTarget] = useState<AdminUser | null>(null);
const [newRole, setNewRole] = useState('');
const { data, isLoading } = useAdminUsers({
page,
pageSize: 20,
search: search || undefined,
role: roleFilter || undefined,
status: statusFilter || undefined,
sortBy,
sortOrder,
});
const suspendMutation = useSuspendUser();
const reactivateMutation = useReactivateUser();
const roleChangeMutation = useUpdateUserRole();
const handleSort = (column: string) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
const handleSuspend = () => {
if (!suspendTarget) return;
suspendMutation.mutate(
{ userId: suspendTarget.id, reason: suspendReason },
{
onSuccess: () => {
setSuspendTarget(null);
setSuspendReason('');
},
}
);
};
const handleReactivate = (user: AdminUser) => {
reactivateMutation.mutate(user.id);
};
const handleRoleChange = () => {
if (!roleChangeTarget || !newRole) return;
roleChangeMutation.mutate(
{ userId: roleChangeTarget.id, role: newRole },
{
onSuccess: () => {
setRoleChangeTarget(null);
setNewRole('');
},
}
);
};
const SortIcon = ({ column }: { column: string }) => {
if (sortBy !== column) return <span className="text-gray-300 ml-1">↕</span>;
return <span className="ml-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>;
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Quản Lý Users
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Quản lý users, roles, và trạng thái tài khoản
</p>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Tìm theo tên hoặc email..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => {
setRoleFilter(e.target.value);
setPage(1);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Tất Cả Roles</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
<option value="SUPER_ADMIN">Super Admin</option>
</select>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Tất Cả Status</option>
<option value="ACTIVE">Active</option>
<option value="SUSPENDED">Suspended</option>
</select>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
onClick={() => handleSort('name')}
>
User <SortIcon column="name" />
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
onClick={() => handleSort('role')}
>
Role <SortIcon column="role" />
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
onClick={() => handleSort('urlCount')}
>
URLs <SortIcon column="urlCount" />
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
onClick={() => handleSort('createdAt')}
>
Ngày Tham Gia <SortIcon column="createdAt" />
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Hành Động
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
</td>
<td className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
</td>
<td className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
</td>
<td className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-12" />
</td>
<td className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</td>
<td className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
</td>
</tr>
))
) : data?.data.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Không tìm thấy user nào phù hợp với bộ lọc.
</td>
</tr>
) : (
data?.data.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-6 py-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{user.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{user.email}
</p>
</div>
</td>
<td className="px-6 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-6 py-4">
<StatusBadge status={user.status} />
</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{user.urlCount}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{format(new Date(user.createdAt), 'dd/MM/yyyy')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end space-x-2">
{user.status === 'ACTIVE' ? (
<button
onClick={() => setSuspendTarget(user)}
className="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium"
>
Suspend
</button>
) : (
<button
onClick={() => handleReactivate(user)}
disabled={reactivateMutation.isPending}
className="text-sm text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 font-medium"
>
Reactivate
</button>
)}
<button
onClick={() => {
setRoleChangeTarget(user);
setNewRole(user.role);
}}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
>
Đổi Role
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Hiển thị {(page - 1) * 20 + 1} đến {Math.min(page * 20, data.total)} trong tổng{' '}
{data.total} users
</p>
<div className="flex space-x-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Trước
</button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
{page} / {data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Sau
</button>
</div>
</div>
)}
</div>
{/* Suspend Modal */}
<ConfirmModal
isOpen={!!suspendTarget}
title="Suspend User"
message={`Bạn có chắc muốn suspend ${suspendTarget?.name}? Họ sẽ mất quyền truy cập tài khoản.`}
confirmLabel={suspendMutation.isPending ? 'Đang suspend...' : 'Suspend User'}
onConfirm={handleSuspend}
onCancel={() => {
setSuspendTarget(null);
setSuspendReason('');
}}
>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Lý do (bắt buộc)
</label>
<textarea
value={suspendReason}
onChange={(e) => setSuspendReason(e.target.value)}
placeholder="Tại sao user này bị suspend?"
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
placeholder-gray-400 focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</ConfirmModal>
{/* Role Change Modal */}
<ConfirmModal
isOpen={!!roleChangeTarget}
title="Đổi Role User"
message={`Thay đổi role cho ${roleChangeTarget?.name}`}
confirmLabel={roleChangeMutation.isPending ? 'Đang cập nhật...' : 'Cập Nhật Role'}
confirmVariant="primary"
onConfirm={handleRoleChange}
onCancel={() => {
setRoleChangeTarget(null);
setNewRole('');
}}
>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role Mới
</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
<option value="SUPER_ADMIN">Super Admin</option>
</select>
</div>
</ConfirmModal>
</div>
);
}Code khá nhiều, nhưng patterns rất chuẩn. Mọi admin data table đều theo cùng hình dạng:
- Filter bar ở trên với search input và dropdown selects
- Sortable table với column headers có thể click
- Action buttons mỗi row mở confirmation modals
- Pagination ở dưới với thông tin trang
ConfirmModal component có thể tái sử dụng — mình truyền children để thêm custom form fields (như textarea lý do suspend hay select chọn role).
URL Moderation Queue
Moderation queue là nơi admin review và quản lý tất cả shortened URLs. Nó có tabs để filter theo status, bulk selection với checkboxes, và batch operations:
// src/pages/admin/AdminUrls.tsx
import { useState } from 'react';
import { format } from 'date-fns';
import { useAdminUrls, useFlagUrl, useDisableUrl, useBulkDisableUrls } from '../../hooks/useAdminApi';
import { AdminUrl } from '../../api/adminApi';
type TabStatus = '' | 'FLAGGED' | 'DISABLED';
function UrlStatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
ACTIVE: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
FLAGGED: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400',
DISABLED: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || styles.ACTIVE}`}>
{status}
</span>
);
}
function truncateUrl(url: string, maxLength: number = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + '...';
}
export function AdminUrls() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<TabStatus>('');
const [sortBy, setSortBy] = useState('createdAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [flagTarget, setFlagTarget] = useState<AdminUrl | null>(null);
const [flagReason, setFlagReason] = useState('');
const { data, isLoading } = useAdminUrls({
page,
pageSize: 20,
search: search || undefined,
status: activeTab || undefined,
sortBy,
sortOrder,
});
const flagMutation = useFlagUrl();
const disableMutation = useDisableUrl();
const bulkDisableMutation = useBulkDisableUrls();
const tabs: { label: string; value: TabStatus; count?: number }[] = [
{ label: 'Tất Cả URLs', value: '' },
{ label: 'Bị Flag', value: 'FLAGGED' },
{ label: 'Đã Disable', value: 'DISABLED' },
];
const toggleSelect = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedIds(next);
};
const toggleSelectAll = () => {
if (!data) return;
if (selectedIds.size === data.data.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(data.data.map((u) => u.id)));
}
};
const handleBulkDisable = () => {
if (selectedIds.size === 0) return;
if (!confirm(`Disable ${selectedIds.size} URLs đã chọn?`)) return;
bulkDisableMutation.mutate(Array.from(selectedIds), {
onSuccess: () => setSelectedIds(new Set()),
});
};
const handleFlag = () => {
if (!flagTarget) return;
flagMutation.mutate(
{ urlId: flagTarget.id, reason: flagReason },
{
onSuccess: () => {
setFlagTarget(null);
setFlagReason('');
},
}
);
};
const handleSort = (column: string) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
const SortIcon = ({ column }: { column: string }) => {
if (sortBy !== column) return <span className="text-gray-300 ml-1">↕</span>;
return <span className="ml-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>;
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
URL Moderation
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Review, flag, và quản lý shortened URLs
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => {
setActiveTab(tab.value);
setPage(1);
setSelectedIds(new Set());
}}
className={`py-3 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === tab.value
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Search và bulk actions */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<input
type="text"
placeholder="Tìm theo short code hoặc URL..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full sm:w-96 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{selectedIds.size > 0 && (
<div className="flex items-center space-x-3">
<span className="text-sm text-gray-500 dark:text-gray-400">
Đã chọn {selectedIds.size}
</span>
<button
onClick={handleBulkDisable}
disabled={bulkDisableMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg
disabled:opacity-50"
>
{bulkDisableMutation.isPending ? 'Đang disable...' : 'Disable Đã Chọn'}
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300
bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Bỏ Chọn
</button>
</div>
)}
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={data?.data.length ? selectedIds.size === data.data.length : false}
onChange={toggleSelectAll}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600
focus:ring-blue-500 dark:bg-gray-700"
/>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => handleSort('shortCode')}
>
Short Code <SortIcon column="shortCode" />
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
URL Gốc
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Người Tạo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => handleSort('clickCount')}
>
Clicks <SortIcon column="clickCount" />
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => handleSort('createdAt')}
>
Ngày Tạo <SortIcon column="createdAt" />
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Hành Động
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
</td>
))}
</tr>
))
) : data?.data.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Không tìm thấy URL nào phù hợp.
</td>
</tr>
) : (
data?.data.map((url) => (
<tr key={url.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedIds.has(url.id)}
onChange={() => toggleSelect(url.id)}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600
focus:ring-blue-500 dark:bg-gray-700"
/>
</td>
<td className="px-6 py-4">
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-blue-600 dark:text-blue-400">
{url.shortCode}
</code>
</td>
<td className="px-6 py-4">
<a
href={url.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400"
title={url.originalUrl}
>
{truncateUrl(url.originalUrl)}
</a>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{url.userEmail}
</td>
<td className="px-6 py-4">
<UrlStatusBadge status={url.status} />
</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{url.clickCount.toLocaleString()}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{format(new Date(url.createdAt), 'dd/MM/yyyy')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end space-x-2">
{url.status === 'ACTIVE' && (
<>
<button
onClick={() => setFlagTarget(url)}
className="text-sm text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 font-medium"
>
Flag
</button>
<button
onClick={() => disableMutation.mutate(url.id)}
className="text-sm text-red-600 hover:text-red-800 dark:text-red-400 font-medium"
>
Disable
</button>
</>
)}
{url.status === 'FLAGGED' && (
<button
onClick={() => disableMutation.mutate(url.id)}
className="text-sm text-red-600 hover:text-red-800 dark:text-red-400 font-medium"
>
Disable
</button>
)}
{url.status === 'DISABLED' && (
<button
onClick={() => {/* reactivate logic */}}
className="text-sm text-green-600 hover:text-green-800 dark:text-green-400 font-medium"
>
Reactivate
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Hiển thị {(page - 1) * 20 + 1} đến {Math.min(page * 20, data.total)} trong tổng{' '}
{data.total} URLs
</p>
<div className="flex space-x-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Trước
</button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
{page} / {data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Sau
</button>
</div>
</div>
)}
</div>
{/* Flag Modal */}
{flagTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setFlagTarget(null)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Flag URL
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Flag <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{flagTarget.shortCode}</code> để review.
</p>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Lý do
</label>
<textarea
value={flagReason}
onChange={(e) => setFlagReason(e.target.value)}
placeholder="Tại sao URL này bị flag?"
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
placeholder-gray-400 focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
/>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => {
setFlagTarget(null);
setFlagReason('');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300
bg-gray-100 dark:bg-gray-700 rounded-lg"
>
Hủy
</button>
<button
onClick={handleFlag}
disabled={flagMutation.isPending || !flagReason.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700
rounded-lg disabled:opacity-50"
>
{flagMutation.isPending ? 'Đang flag...' : 'Flag URL'}
</button>
</div>
</div>
</div>
)}
</div>
);
}Pattern bulk action khá đơn giản: duy trì một Set<string> chứa IDs đã chọn, check/uncheck bằng toggleSelect, rồi gửi toàn bộ set tới bulk API endpoint. Checkbox "select all" chuyển đổi giữa chọn-tất-cả-trên-trang và không-chọn-gì.
Audit Log Viewer
Audit log viewer cho admin một timeline của mọi administrative action trong hệ thống. Mỗi row hiển thị ai đã làm gì và khi nào, với expandable details cho full context:
// src/pages/admin/AdminAuditLogs.tsx
import { useState } from 'react';
import { format } from 'date-fns';
import { useAuditLogs } from '../../hooks/useAdminApi';
import { AuditLog } from '../../api/adminApi';
function formatAuditAction(log: AuditLog): string {
const actions: Record<string, string> = {
'user.suspend': 'đã suspend user',
'user.reactivate': 'đã reactivate user',
'user.role_change': 'đã đổi role cho user',
'url.flag': 'đã flag URL',
'url.disable': 'đã disable URL',
'url.reactivate': 'đã reactivate URL',
'url.bulk_disable': 'đã bulk-disable URLs',
'url.bulk_delete': 'đã bulk-delete URLs',
'blocklist.add': 'đã block domain',
'blocklist.remove': 'đã unblock domain',
'settings.update': 'đã cập nhật system settings',
};
return actions[log.action] || log.action;
}
function ActionBadge({ action }: { action: string }) {
const category = action.split('.')[0];
const styles: Record<string, string> = {
user: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
url: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
blocklist: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
settings: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[category] || 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}>
{action}
</span>
);
}
function LogDetailsPanel({ details }: { details: Record<string, unknown> }) {
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mt-2">
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(details, null, 2)}
</pre>
</div>
);
}
export function AdminAuditLogs() {
const [page, setPage] = useState(1);
const [actionFilter, setActionFilter] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const { data, isLoading } = useAuditLogs({
page,
pageSize: 30,
action: actionFilter || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
const toggleExpanded = (id: string) => {
const next = new Set(expandedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setExpandedIds(next);
};
const actionTypes = [
{ value: '', label: 'Tất Cả Actions' },
{ value: 'user.suspend', label: 'User Bị Suspend' },
{ value: 'user.reactivate', label: 'User Được Reactivate' },
{ value: 'user.role_change', label: 'Đổi Role' },
{ value: 'url.flag', label: 'URL Bị Flag' },
{ value: 'url.disable', label: 'URL Bị Disable' },
{ value: 'url.reactivate', label: 'URL Được Reactivate' },
{ value: 'url.bulk_disable', label: 'Bulk URL Disable' },
{ value: 'blocklist.add', label: 'Block Domain' },
{ value: 'blocklist.remove', label: 'Unblock Domain' },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Audit Logs
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Theo dõi tất cả administrative actions trong hệ thống
</p>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div className="flex flex-col sm:flex-row gap-4">
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(1);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{actionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<div className="flex items-center space-x-2">
<input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setPage(1);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<span className="text-gray-400">đến</span>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setPage(1);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{(actionFilter || startDate || endDate) && (
<button
onClick={() => {
setActionFilter('');
setStartDate('');
setEndDate('');
setPage(1);
}}
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300
bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Xóa Bộ Lọc
</button>
)}
</div>
</div>
{/* Log entries */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Thời Gian
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Admin
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Mô Tả
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
IP Address
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Chi Tiết
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="animate-pulse">
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-6 py-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</td>
))}
</tr>
))
) : data?.data.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Không tìm thấy audit log nào phù hợp với bộ lọc.
</td>
</tr>
) : (
data?.data.map((log) => (
<>
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{format(new Date(log.createdAt), 'dd/MM/yyyy HH:mm:ss')}
</td>
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
{log.adminName}
</td>
<td className="px-6 py-4">
<ActionBadge action={log.action} />
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">{log.adminName}</span>{' '}
{formatAuditAction(log)}{' '}
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">
{log.targetId}
</code>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 font-mono">
{log.ipAddress}
</td>
<td className="px-6 py-4 text-center">
<button
onClick={() => toggleExpanded(log.id)}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
>
{expandedIds.has(log.id) ? 'Ẩn' : 'Xem'}
</button>
</td>
</tr>
{expandedIds.has(log.id) && (
<tr key={`${log.id}-details`}>
<td colSpan={6} className="px-6 py-2">
<LogDetailsPanel details={log.details} />
</td>
</tr>
)}
</>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Hiển thị {(page - 1) * 30 + 1} đến {Math.min(page * 30, data.total)} trong tổng{' '}
{data.total} entries
</p>
<div className="flex space-x-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Trước
</button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
{page} / {data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Sau
</button>
</div>
</div>
)}
</div>
</div>
);
}Pattern expandable rows đơn giản: duy trì Set<string> chứa IDs đã expand. Khi user click "Xem", thêm ID vào set và render thêm một <tr> với JSON details. LogDetailsPanel render raw details object dạng pretty-printed JSON — hữu ích cho debugging và compliance auditing.
Dark Mode Support
Vì mình đã build user-facing frontend với dark mode của Tailwind ở Phase 8, admin pages tự động kế thừa cùng hệ thống. Mọi component trong bài này đều sử dụng dark: variants nhất quán. Đây là pattern:
// Pattern dark mode nhất quán dùng xuyên suốt admin components
// Backgrounds
// Light: bg-white Dark: dark:bg-gray-800 (cards)
// Light: bg-gray-50 Dark: dark:bg-gray-700/50 (table headers)
// Light: bg-gray-100 Dark: dark:bg-gray-900 (page background)
// Text
// Light: text-gray-900 Dark: dark:text-white (headings)
// Light: text-gray-700 Dark: dark:text-gray-300 (body text)
// Light: text-gray-500 Dark: dark:text-gray-400 (secondary text)
// Borders
// Light: border-gray-200 Dark: dark:border-gray-700 (dividers)
// Interactive
// Light: hover:bg-gray-50 Dark: dark:hover:bg-gray-700/30 (table rows)Không cần config thêm gì. ThemeProvider và theme toggle từ Phase 8 hoạt động cho admin pages luôn. Chỉ cần nhất quán dùng dark variants trên mọi element là admin UI sẽ đẹp ở cả hai mode.
Một tip: luôn test admin components ở dark mode trong lúc dev. Rất dễ quên một dark: variant trên border hay background, để lại một element trắng chói giữa interface tối.
Những Lỗi Thường Gặp
1. Không Handle Loading và Error States
Mọi admin page đều fetch data bất đồng bộ. Nếu bỏ qua loading states, user sẽ thấy flash nội dung trống. Nếu bỏ qua error states, user cứ nhìn trang trắng mà không biết chuyện gì xảy ra.
// Sai — không handle loading hay error
function AdminPage() {
const { data } = useAdminUsers({ page: 1 });
return <UserTable users={data.data} />; // crash nếu data undefined
}
// Đúng — handle tất cả states
function AdminPage() {
const { data, isLoading, error } = useAdminUsers({ page: 1 });
if (isLoading) return <TableSkeleton />;
if (error) return <ErrorMessage message="Không thể tải danh sách users" />;
if (!data || data.data.length === 0) return <EmptyState />;
return <UserTable users={data.data} />;
}2. Quên Invalidate React Query Cache Sau Mutations
Sau khi suspend user hay disable URL, list phải reflect thay đổi ngay lập tức. Nếu quên invalidateQueries, data cũ vẫn hiển thị cho đến khi user refresh tay.
// Sai — mutation thành công nhưng UI hiển thị data cũ
const mutation = useMutation({
mutationFn: (userId: string) => adminApi.suspendUser(userId, 'spam'),
});
// Đúng — invalidate các queries liên quan sau mutation
const mutation = useMutation({
mutationFn: (userId: string) => adminApi.suspendUser(userId, 'spam'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
},
});3. Thiếu Route Guard Trên Admin Pages
Nếu bạn thêm admin routes mà quên wrap bằng AdminRoute, bất kỳ authenticated user nào cũng có thể truy cập admin pages. Luôn wrap toàn bộ admin layout:
// Sai — ai cũng vào được /admin
<Route path="/admin" element={<AdminDashboard />} />
// Đúng — chỉ admin mới qua được
<Route element={<AdminRoute><AdminLayout /></AdminRoute>}>
<Route path="/admin" element={<AdminDashboard />} />
</Route>4. Không Xác Nhận Trước Destructive Actions
Suspend user hay delete URLs là những action nghiêm trọng. Đừng bao giờ thực hiện chỉ bằng một click. Luôn hiện confirmation modal với mô tả rõ ràng hậu quả:
// Sai — destructive action tức thì
<button onClick={() => suspendUser(user.id)}>Suspend</button>
// Đúng — xác nhận với lý do
<button onClick={() => setSuspendTarget(user)}>Suspend</button>
<ConfirmModal
isOpen={!!suspendTarget}
title="Suspend User"
message={`Hành động này sẽ thu hồi quyền truy cập của ${suspendTarget?.name} ngay lập tức.`}
onConfirm={handleSuspendWithReason}
onCancel={() => setSuspendTarget(null)}
/>Tiếp Theo Là Gì
Giờ mình đã có admin dashboard hoàn chỉnh: metric cards hiển thị sức khỏe hệ thống trong nháy mắt, interactive charts để phân tích sâu, user management với full CRUD operations, URL moderation với bulk actions, và audit log viewer cho compliance tracking.
Trong Phase 14: SEO & Link Previews, mình sẽ thêm Open Graph metadata fetching cho shortened URLs, custom link preview pages, sitemap generation, và structured data markup — giúp shortened URLs của mình trông chuyên nghiệp khi được chia sẻ trên social media và messaging apps.
Series: Xây Dựng URL Shortener
Previous: Phase 12: System Analytics & Audit Logs
Next: Phase 14: SEO & Link Previews
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.