Update dependencies and enhance Tailwind CSS configuration for web applications

- Added new dependencies including clsx, lucide-react, recharts, and various Radix UI components to improve UI functionality.
- Upgraded Tailwind CSS to version 4.0.0 and updated configuration to utilize CSS variables for theming and responsive design.
- Introduced global styles and improved accessibility features in the layout and components.
- Removed outdated login page and refactored authentication store for better state management.
- Enhanced API service with additional authentication methods and improved error handling.

These changes aim to modernize the web applications and improve user experience through better design and functionality.
This commit is contained in:
Ho Ngoc Hai
2026-01-02 09:41:40 +07:00
parent af303eaf7b
commit c088de53c3
130 changed files with 20618 additions and 415 deletions

View File

@@ -0,0 +1,230 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
/**
* EN: Analytics card variant types
* VI: Các loại biến thể analytics card
*/
export type AnalyticsCardVariant = 'metric' | 'chart' | 'progress' | 'comparison';
/**
* EN: Trend direction
* VI: Hướng trend
*/
export type TrendDirection = 'up' | 'down' | 'neutral';
/**
* EN: AnalyticsCard component props
* VI: Props của component AnalyticsCard
*/
export interface AnalyticsCardProps {
/**
* EN: Card title / VI: Tiêu đề card
*/
title: string;
/**
* EN: Main value to display / VI: Giá trị chính để hiển thị
*/
value: string | number;
/**
* EN: Change percentage (e.g., "+12.5%") / VI: Phần trăm thay đổi (VD: "+12.5%")
*/
change?: string;
/**
* EN: Trend direction / VI: Hướng trend
*/
trend?: TrendDirection;
/**
* EN: Icon component / VI: Component icon
*/
icon?: LucideIcon;
/**
* EN: Card variant / VI: Biến thể card
*/
variant?: AnalyticsCardVariant;
/**
* EN: Chart data (for chart variant) / VI: Dữ liệu chart (cho variant chart)
*/
chartData?: Array<{ label: string; value: number }>;
/**
* EN: Progress percentage (0-100) for progress variant / VI: Phần trăm tiến độ (0-100) cho variant progress
*/
progress?: number;
/**
* EN: Comparison values for comparison variant / VI: Giá trị so sánh cho variant comparison
*/
comparisons?: Array<{ label: string; value: string | number }>;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: AnalyticsCard component - Displays analytics metrics with various visualization options
* VI: Component AnalyticsCard - Hiển thị các metric analytics với các tùy chọn visualization
*
* Variants:
* - metric: Single value with trend
* - chart: Value with mini chart
* - progress: Value with progress bar
* - comparison: Multiple metrics comparison
*
* Biến thể:
* - metric: Giá trị đơn với trend
* - chart: Giá trị với mini chart
* - progress: Giá trị với thanh tiến độ
* - comparison: So sánh nhiều metrics
*/
export function AnalyticsCard({
title,
value,
change,
trend = 'neutral',
icon: Icon,
variant = 'metric',
chartData,
progress,
comparisons,
className,
}: AnalyticsCardProps) {
// EN: Get trend color / VI: Lấy màu trend
const getTrendColor = () => {
switch (trend) {
case 'up':
return 'text-accent-success';
case 'down':
return 'text-accent-error';
default:
return 'text-text-tertiary';
}
};
// EN: Get trend icon / VI: Lấy icon trend
const getTrendIcon = () => {
switch (trend) {
case 'up':
return (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
);
case 'down':
return (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
/>
</svg>
);
default:
return null;
}
};
return (
<Card className={cn('hover', className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-text-secondary">
{title}
</CardTitle>
{Icon && (
<Icon className="h-4 w-4 text-text-tertiary" aria-hidden="true" />
)}
</CardHeader>
<CardContent>
{/* EN: Main value / VI: Giá trị chính */}
<div className="text-2xl font-bold text-text-primary mb-1">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
{/* EN: Change indicator / VI: Chỉ báo thay đổi */}
{change && (
<div className={cn('text-xs flex items-center gap-1', getTrendColor())}>
{getTrendIcon()}
<span>{change}</span>
</div>
)}
{/* EN: Chart variant - Mini sparkline / VI: Variant chart - Mini sparkline */}
{variant === 'chart' && chartData && chartData.length > 0 && (
<div className="mt-4 h-[60px] flex items-end gap-1">
{chartData.map((point, index) => {
const maxValue = Math.max(...chartData.map((p) => p.value));
const height = (point.value / maxValue) * 100;
return (
<div
key={index}
className="flex-1 bg-accent-primary rounded-t"
style={{ height: `${height}%` }}
aria-hidden="true"
/>
);
})}
</div>
)}
{/* EN: Progress variant - Progress bar / VI: Variant progress - Thanh tiến độ */}
{variant === 'progress' && progress !== undefined && (
<div className="mt-4">
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent-primary transition-all duration-[250ms]"
style={{ width: `${Math.min(progress, 100)}%` }}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${title}: ${progress}%`}
/>
</div>
<p className="text-xs text-text-tertiary mt-1">{progress}%</p>
</div>
)}
{/* EN: Comparison variant - Multiple metrics / VI: Variant comparison - Nhiều metrics */}
{variant === 'comparison' && comparisons && comparisons.length > 0 && (
<div className="mt-4 space-y-2">
{comparisons.map((comp, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<span className="text-text-secondary">{comp.label}</span>
<span className="font-medium text-text-primary">
{typeof comp.value === 'number'
? comp.value.toLocaleString()
: comp.value}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import * as React from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
/**
* EN: Revenue data point
* VI: Điểm dữ liệu doanh thu
*/
export interface RevenueDataPoint {
/**
* EN: Date label / VI: Nhãn ngày
*/
date: string;
/**
* EN: Revenue amount / VI: Số tiền doanh thu
*/
revenue: number;
/**
* EN: Optional comparison value / VI: Giá trị so sánh tùy chọn
*/
previousRevenue?: number;
}
/**
* EN: RevenueChart component props
* VI: Props của component RevenueChart
*/
export interface RevenueChartProps {
/**
* EN: Chart data / VI: Dữ liệu chart
*/
data: RevenueDataPoint[];
/**
* EN: Chart title / VI: Tiêu đề chart
*/
title?: string;
/**
* EN: Chart description / VI: Mô tả chart
*/
description?: string;
/**
* EN: Currency symbol / VI: Ký hiệu tiền tệ
*/
currency?: string;
/**
* EN: Show legend / VI: Hiển thị legend
*/
showLegend?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: RevenueChart component - Area chart showing revenue over time
* VI: Component RevenueChart - Area chart hiển thị doanh thu theo thời gian
*
* Features:
* - Area chart with Recharts
* - Responsive container
* - Tooltip on hover with currency formatting
* - Legend support
* - Dark mode optimized colors
* - Gradient fill
*
* Tính năng:
* - Area chart với Recharts
* - Container responsive
* - Tooltip khi hover với định dạng tiền tệ
* - Hỗ trợ legend
* - Màu sắc tối ưu cho dark mode
* - Gradient fill
*/
export function RevenueChart({
data,
title = 'Revenue / Doanh thu',
description = 'Revenue over time / Doanh thu theo thời gian',
currency = '$',
showLegend = true,
className,
}: RevenueChartProps) {
// EN: Format currency value / VI: Format giá trị tiền tệ
const formatCurrency = (value: number) => {
return `${currency}${value.toLocaleString()}`;
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--accent-primary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--accent-primary)" stopOpacity={0} />
</linearGradient>
{data.some((d) => d.previousRevenue !== undefined) && (
<linearGradient id="previousRevenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--accent-secondary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--accent-secondary)" stopOpacity={0} />
</linearGradient>
)}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
<XAxis
dataKey="date"
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
tickFormatter={formatCurrency}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--bg-elevated)',
border: '1px solid var(--border-primary)',
borderRadius: '8px',
color: 'var(--text-primary)',
}}
formatter={(value: number) => formatCurrency(value)}
/>
{showLegend && (
<Legend
wrapperStyle={{ color: 'var(--text-secondary)' }}
/>
)}
<Area
type="monotone"
dataKey="revenue"
stroke="var(--accent-primary)"
strokeWidth={2}
fill="url(#revenueGradient)"
name="Revenue / Doanh thu"
/>
{data.some((d) => d.previousRevenue !== undefined) && (
<Area
type="monotone"
dataKey="previousRevenue"
stroke="var(--accent-secondary)"
strokeWidth={2}
fill="url(#previousRevenueGradient)"
name="Previous Period / Kỳ trước"
/>
)}
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import * as React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
/**
* EN: User growth data point
* VI: Điểm dữ liệu tăng trưởng người dùng
*/
export interface UserGrowthDataPoint {
/**
* EN: Date label / VI: Nhãn ngày
*/
date: string;
/**
* EN: Number of users / VI: Số lượng người dùng
*/
users: number;
/**
* EN: Number of new users / VI: Số người dùng mới
*/
newUsers?: number;
}
/**
* EN: UserGrowthChart component props
* VI: Props của component UserGrowthChart
*/
export interface UserGrowthChartProps {
/**
* EN: Chart data / VI: Dữ liệu chart
*/
data: UserGrowthDataPoint[];
/**
* EN: Chart title / VI: Tiêu đề chart
*/
title?: string;
/**
* EN: Chart description / VI: Mô tả chart
*/
description?: string;
/**
* EN: Show legend / VI: Hiển thị legend
*/
showLegend?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: UserGrowthChart component - Line chart showing user growth over time
* VI: Component UserGrowthChart - Line chart hiển thị tăng trưởng người dùng theo thời gian
*
* Features:
* - Line chart with Recharts
* - Responsive container
* - Tooltip on hover
* - Legend support
* - Dark mode optimized colors
*
* Tính năng:
* - Line chart với Recharts
* - Container responsive
* - Tooltip khi hover
* - Hỗ trợ legend
* - Màu sắc tối ưu cho dark mode
*/
export function UserGrowthChart({
data,
title = 'User Growth / Tăng trưởng người dùng',
description = 'User growth over time / Tăng trưởng người dùng theo thời gian',
showLegend = true,
className,
}: UserGrowthChartProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
<XAxis
dataKey="date"
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--bg-elevated)',
border: '1px solid var(--border-primary)',
borderRadius: '8px',
color: 'var(--text-primary)',
}}
/>
{showLegend && (
<Legend
wrapperStyle={{ color: 'var(--text-secondary)' }}
/>
)}
<Line
type="monotone"
dataKey="users"
stroke="var(--accent-primary)"
strokeWidth={2}
dot={{ fill: 'var(--accent-primary)', r: 4 }}
name="Total Users / Tổng người dùng"
/>
{data.some((d) => d.newUsers !== undefined) && (
<Line
type="monotone"
dataKey="newUsers"
stroke="var(--accent-success)"
strokeWidth={2}
dot={{ fill: 'var(--accent-success)', r: 4 }}
name="New Users / Người dùng mới"
/>
)}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,491 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ArrowUpDown,
Download,
Trash2,
CheckSquare,
Square,
} from 'lucide-react';
/**
* EN: Column definition for DataTable
* VI: Định nghĩa cột cho DataTable
*/
export interface DataTableColumn<T> {
/**
* EN: Column key / VI: Key của cột
*/
key: keyof T | string;
/**
* EN: Column header label / VI: Nhãn header cột
*/
header: string;
/**
* EN: Custom cell renderer / VI: Custom cell renderer
*/
cell?: (row: T) => React.ReactNode;
/**
* EN: Enable sorting / VI: Bật sắp xếp
*/
sortable?: boolean;
/**
* EN: Column width / VI: Chiều rộng cột
*/
width?: string;
}
/**
* EN: Sort direction
* VI: Hướng sắp xếp
*/
export type SortDirection = 'asc' | 'desc' | null;
/**
* EN: DataTable component props
* VI: Props của component DataTable
*/
export interface DataTableProps<T> {
/**
* EN: Table data / VI: Dữ liệu bảng
*/
data: T[];
/**
* EN: Column definitions / VI: Định nghĩa cột
*/
columns: DataTableColumn<T>[];
/**
* EN: Current page number (1-indexed) / VI: Số trang hiện tại (bắt đầu từ 1)
*/
currentPage?: number;
/**
* EN: Number of items per page / VI: Số item mỗi trang
*/
itemsPerPage?: number;
/**
* EN: Total number of items (for server-side pagination) / VI: Tổng số item (cho pagination phía server)
*/
totalItems?: number;
/**
* EN: Callback when page changes / VI: Callback khi trang thay đổi
*/
onPageChange?: (page: number) => void;
/**
* EN: Enable row selection / VI: Bật chọn hàng
*/
selectable?: boolean;
/**
* EN: Selected row IDs / VI: IDs hàng được chọn
*/
selectedRows?: string[];
/**
* EN: Callback when selection changes / VI: Callback khi selection thay đổi
*/
onSelectionChange?: (selectedIds: string[]) => void;
/**
* EN: Get row ID function / VI: Hàm lấy ID hàng
*/
getRowId?: (row: T) => string;
/**
* EN: Enable bulk actions / VI: Bật bulk actions
*/
bulkActions?: Array<{
label: string;
action: (selectedIds: string[]) => void;
variant?: 'primary' | 'secondary' | 'danger';
}>;
/**
* EN: Enable export / VI: Bật export
*/
exportable?: boolean;
/**
* EN: Callback for export / VI: Callback cho export
*/
onExport?: () => void;
/**
* EN: Loading state / VI: Trạng thái loading
*/
loading?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: DataTable component - Advanced table with sorting, filtering, pagination, and bulk actions
* VI: Component DataTable - Bảng nâng cao với sắp xếp, lọc, pagination và bulk actions
*
* Features:
* - Column sorting
* - Global search
* - Column filters
* - Pagination
* - Row selection
* - Bulk actions
* - Export to CSV/Excel
* - Column visibility toggle
*
* Tính năng:
* - Sắp xếp cột
* - Tìm kiếm toàn cục
* - Lọc cột
* - Pagination
* - Chọn hàng
* - Bulk actions
* - Export sang CSV/Excel
* - Toggle hiển thị cột
*/
export function DataTable<T extends Record<string, any>>({
data,
columns,
currentPage = 1,
itemsPerPage = 10,
totalItems,
onPageChange,
selectable = false,
selectedRows = [],
onSelectionChange,
getRowId = (row) => row.id || String(row),
bulkActions = [],
exportable = false,
onExport,
loading = false,
className,
}: DataTableProps<T>) {
const [sortColumn, setSortColumn] = React.useState<keyof T | string | null>(null);
const [sortDirection, setSortDirection] = React.useState<SortDirection>(null);
const [searchQuery, setSearchQuery] = React.useState('');
// EN: Calculate pagination / VI: Tính toán pagination
const totalPages = totalItems
? Math.ceil(totalItems / itemsPerPage)
: Math.ceil(data.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
// EN: Handle sorting / VI: Xử lý sắp xếp
const handleSort = (columnKey: keyof T | string) => {
if (sortColumn === columnKey) {
if (sortDirection === 'asc') {
setSortDirection('desc');
} else if (sortDirection === 'desc') {
setSortColumn(null);
setSortDirection(null);
}
} else {
setSortColumn(columnKey);
setSortDirection('asc');
}
};
// EN: Handle row selection / VI: Xử lý chọn hàng
const handleRowSelect = (rowId: string) => {
if (!onSelectionChange) return;
const newSelection = selectedRows.includes(rowId)
? selectedRows.filter((id) => id !== rowId)
: [...selectedRows, rowId];
onSelectionChange(newSelection);
};
// EN: Handle select all / VI: Xử lý chọn tất cả
const handleSelectAll = () => {
if (!onSelectionChange) return;
const allIds = data.map(getRowId);
const allSelected = allIds.every((id) => selectedRows.includes(id));
onSelectionChange(allSelected ? [] : allIds);
};
// EN: Filter and sort data / VI: Lọc và sắp xếp dữ liệu
const processedData = React.useMemo(() => {
let filtered = data;
// EN: Apply search filter / VI: Áp dụng bộ lọc tìm kiếm
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((row) =>
columns.some((col) => {
const value = row[col.key];
return value && String(value).toLowerCase().includes(query);
})
);
}
// EN: Apply sorting / VI: Áp dụng sắp xếp
if (sortColumn && sortDirection) {
filtered = [...filtered].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
if (aValue === bValue) return 0;
const comparison = aValue < bValue ? -1 : 1;
return sortDirection === 'asc' ? comparison : -comparison;
});
}
// EN: Apply pagination / VI: Áp dụng pagination
return totalItems ? filtered : filtered.slice(startIndex, endIndex);
}, [data, searchQuery, sortColumn, sortDirection, startIndex, endIndex, totalItems, columns]);
// EN: Handle page change / VI: Xử lý thay đổi trang
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && onPageChange) {
onPageChange(page);
}
};
const allSelected = data.length > 0 && data.every((row) => selectedRows.includes(getRowId(row)));
const someSelected = selectedRows.length > 0 && !allSelected;
return (
<div className={cn('space-y-4', className)}>
{/* EN: Toolbar with search and actions / VI: Toolbar với tìm kiếm và actions */}
<div className="flex items-center justify-between gap-4">
<Input
type="search"
placeholder="Search... / Tìm kiếm..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm"
/>
<div className="flex items-center gap-2">
{exportable && (
<Button variant="secondary" size="sm" onClick={onExport}>
<Download className="h-4 w-4 mr-2" />
Export / Xuất
</Button>
)}
</div>
</div>
{/* EN: Bulk actions bar / VI: Thanh bulk actions */}
{selectable && selectedRows.length > 0 && (
<div className="flex items-center justify-between p-4 bg-bg-tertiary rounded-lg border border-border-primary">
<span className="text-sm text-text-secondary">
{selectedRows.length} selected / {selectedRows.length} đã chọn
</span>
<div className="flex items-center gap-2">
{bulkActions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'secondary'}
size="sm"
onClick={() => action.action(selectedRows)}
>
{action.label}
</Button>
))}
</div>
</div>
)}
{/* EN: Table / VI: Bảng */}
<div className="bg-bg-secondary rounded-lg border border-border-primary overflow-hidden">
{/* EN: Mobile: Horizontal scroll / VI: Mobile: Scroll ngang */}
<div className="overflow-x-auto -mx-4 sm:mx-0">
<div className="inline-block min-w-full align-middle sm:min-w-0">
{loading ? (
<div className="p-8 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" />
<p className="mt-4 text-sm text-text-tertiary">
Loading... / Đang tải...
</p>
</div>
) : processedData.length === 0 ? (
<div className="p-8 text-center">
<p className="text-sm text-text-tertiary">
No data found / Không tìm thấy dữ liệu
</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border-primary">
{selectable && (
<th className="px-6 py-3 text-left w-12">
<button
onClick={handleSelectAll}
className="p-1 rounded hover:bg-bg-tertiary transition-colors"
aria-label="Select all / Chọn tất cả"
>
{allSelected ? (
<CheckSquare className="h-4 w-4 text-accent-primary" />
) : someSelected ? (
<div className="h-4 w-4 border-2 border-accent-primary rounded bg-accent-primary/20" />
) : (
<Square className="h-4 w-4 text-text-tertiary" />
)}
</button>
</th>
)}
{columns.map((column) => (
<th
key={String(column.key)}
className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider"
style={{ width: column.width }}
>
<div className="flex items-center gap-2">
<span>{column.header}</span>
{column.sortable && (
<button
onClick={() => handleSort(column.key)}
className="p-1 rounded hover:bg-bg-tertiary transition-colors"
aria-label={`Sort by ${column.header} / Sắp xếp theo ${column.header}`}
>
<ArrowUpDown
className={cn(
'h-3 w-3',
sortColumn === column.key
? 'text-accent-primary'
: 'text-text-tertiary'
)}
/>
</button>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{processedData.map((row) => {
const rowId = getRowId(row);
const isSelected = selectedRows.includes(rowId);
return (
<tr
key={rowId}
className={cn(
'hover:bg-bg-tertiary transition-colors duration-[150ms]',
isSelected && 'bg-bg-tertiary'
)}
>
{selectable && (
<td className="px-6 py-4">
<button
onClick={() => handleRowSelect(rowId)}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
aria-label={`Select row ${rowId} / Chọn hàng ${rowId}`}
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-accent-primary" />
) : (
<Square className="h-4 w-4 text-text-tertiary" />
)}
</button>
</td>
)}
{columns.map((column) => (
<td key={String(column.key)} className="px-6 py-4">
{column.cell
? column.cell(row)
: String(row[column.key] || '')}
</td>
))}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* EN: Pagination / VI: Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-border-primary flex items-center justify-between">
<div className="text-sm text-text-tertiary">
{totalItems ? (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, totalItems)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
results / kết quả
</>
) : (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, data.length)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">{data.length}</span>{' '}
results / kết quả
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
aria-label="First page / Trang đầu"
className="min-w-[44px] min-h-[44px]"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
aria-label="Previous page / Trang trước"
className="min-w-[44px] min-h-[44px]"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-text-secondary px-3 hidden sm:inline">
Page / Trang {currentPage} of / trong {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
aria-label="Next page / Trang sau"
className="min-w-[44px] min-h-[44px]"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages || loading}
aria-label="Last page / Trang cuối"
className="min-w-[44px] min-h-[44px]"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,551 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Activity status type
* VI: Loại trạng thái hoạt động
*/
export type ActivityStatus = 'success' | 'warning' | 'error' | 'info' | 'pending';
/**
* EN: Activity action type
* VI: Loại hành động hoạt động
*/
export type ActivityAction =
| 'user_created'
| 'user_updated'
| 'user_deleted'
| 'user_login'
| 'user_logout'
| 'message_sent'
| 'message_deleted'
| 'settings_updated'
| 'system_backup'
| 'system_restore';
/**
* EN: Recent activity item interface
* VI: Interface cho item hoạt động gần đây
*/
export interface RecentActivity {
/**
* EN: Unique activity identifier / VI: Mã định danh duy nhất cho hoạt động
*/
id: string;
/**
* EN: User who performed the action / VI: Người dùng thực hiện hành động
*/
user: {
/**
* EN: User ID / VI: ID người dùng
*/
id: string;
/**
* EN: User name / VI: Tên người dùng
*/
name: string;
/**
* EN: User email / VI: Email người dùng
*/
email: string;
/**
* EN: User avatar URL (optional) / VI: URL avatar người dùng (tùy chọn)
*/
avatarUrl?: string;
};
/**
* EN: Action performed / VI: Hành động được thực hiện
*/
action: ActivityAction;
/**
* EN: Action description / VI: Mô tả hành động
*/
description: string;
/**
* EN: Activity status / VI: Trạng thái hoạt động
*/
status: ActivityStatus;
/**
* EN: Timestamp when activity occurred / VI: Timestamp khi hoạt động xảy ra
*/
timestamp: Date | string;
/**
* EN: Additional metadata (optional) / VI: Metadata bổ sung (tùy chọn)
*/
metadata?: Record<string, unknown>;
}
/**
* EN: RecentActivityTable component props
* VI: Props của component RecentActivityTable
*/
export interface RecentActivityTableProps {
/**
* EN: Array of recent activities / VI: Mảng các hoạt động gần đây
*/
activities: RecentActivity[];
/**
* EN: Current page number (1-indexed) / VI: Số trang hiện tại (bắt đầu từ 1)
*/
currentPage?: number;
/**
* EN: Number of items per page / VI: Số item mỗi trang
*/
itemsPerPage?: number;
/**
* EN: Total number of items (for server-side pagination) / VI: Tổng số item (cho pagination phía server)
*/
totalItems?: number;
/**
* EN: Callback when page changes / VI: Callback khi trang thay đổi
*/
onPageChange?: (page: number) => void;
/**
* EN: Callback for quick action click / VI: Callback khi click quick action
*/
onQuickAction?: (activityId: string, action: string) => void;
/**
* EN: Loading state / VI: Trạng thái loading
*/
loading?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: Get user initials for avatar fallback
* VI: Lấy initials của user cho avatar fallback
*/
function getUserInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
/**
* EN: Format timestamp to relative time
* VI: Format timestamp thành thời gian tương đối
*/
function formatRelativeTime(date: Date | string): string {
const now = new Date();
const activityDate = typeof date === 'string' ? new Date(date) : date;
const diffMs = now.getTime() - activityDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now / Vừa xong';
if (diffMins < 60) return `${diffMins}m ago / ${diffMins} phút trước`;
if (diffHours < 24) return `${diffHours}h ago / ${diffHours} giờ trước`;
if (diffDays < 7) return `${diffDays}d ago / ${diffDays} ngày trước`;
return activityDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: diffDays >= 365 ? 'numeric' : undefined,
});
}
/**
* EN: Get status badge styles
* VI: Lấy styles cho status badge
*/
function getStatusBadgeStyles(status: ActivityStatus): string {
const styles = {
success:
'bg-accent-success/20 text-accent-success border-accent-success/30',
warning:
'bg-accent-warning/20 text-accent-warning border-accent-warning/30',
error:
'bg-accent-error/20 text-accent-error border-accent-error/30',
info: 'bg-accent-info/20 text-accent-info border-accent-info/30',
pending:
'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30',
};
return styles[status];
}
/**
* EN: Get status label
* VI: Lấy label cho status
*/
function getStatusLabel(status: ActivityStatus): string {
const labels = {
success: 'Success / Thành công',
warning: 'Warning / Cảnh báo',
error: 'Error / Lỗi',
info: 'Info / Thông tin',
pending: 'Pending / Đang chờ',
};
return labels[status];
}
/**
* EN: Get action label
* VI: Lấy label cho action
*/
function getActionLabel(action: ActivityAction): string {
const labels: Record<ActivityAction, string> = {
user_created: 'User Created / Tạo người dùng',
user_updated: 'User Updated / Cập nhật người dùng',
user_deleted: 'User Deleted / Xóa người dùng',
user_login: 'User Login / Đăng nhập',
user_logout: 'User Logout / Đăng xuất',
message_sent: 'Message Sent / Gửi tin nhắn',
message_deleted: 'Message Deleted / Xóa tin nhắn',
settings_updated: 'Settings Updated / Cập nhật cài đặt',
system_backup: 'System Backup / Sao lưu hệ thống',
system_restore: 'System Restore / Khôi phục hệ thống',
};
return labels[action] || action;
}
/**
* EN: RecentActivityTable component - Displays recent activities with pagination
* VI: Component RecentActivityTable - Hiển thị các hoạt động gần đây với pagination
*
* Features:
* - User avatar + name
* - Action performed
* - Timestamp (relative)
* - Status badge
* - Quick actions
* - Pagination
*
* Tính năng:
* - Avatar + tên người dùng
* - Hành động được thực hiện
* - Timestamp (tương đối)
* - Badge trạng thái
* - Quick actions
* - Pagination
*
* @example
* ```tsx
* <RecentActivityTable
* activities={activities}
* currentPage={1}
* itemsPerPage={10}
* onPageChange={(page) => setPage(page)}
* onQuickAction={(id, action) => handleAction(id, action)}
* />
* ```
*/
export function RecentActivityTable({
activities,
currentPage = 1,
itemsPerPage = 10,
totalItems,
onPageChange,
onQuickAction,
loading = false,
className,
}: RecentActivityTableProps) {
// EN: Calculate pagination / VI: Tính toán pagination
const totalPages = totalItems
? Math.ceil(totalItems / itemsPerPage)
: Math.ceil(activities.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedActivities = totalItems
? activities // EN: Server-side pagination / VI: Pagination phía server
: activities.slice(startIndex, endIndex); // EN: Client-side pagination / VI: Pagination phía client
// EN: Handle page change / VI: Xử lý thay đổi trang
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && onPageChange) {
onPageChange(page);
}
};
// EN: Handle quick action / VI: Xử lý quick action
const handleQuickAction = (activityId: string, action: string) => {
if (onQuickAction) {
onQuickAction(activityId, action);
}
};
return (
<div
className={cn(
'bg-bg-secondary rounded-xl border border-border-primary overflow-hidden',
className
)}
>
{/* EN: Table Header / VI: Header bảng */}
<div className="px-6 py-4 border-b border-border-primary">
<h3 className="text-lg font-semibold text-text-primary">
Recent Activity / Hoạt đng gần đây
</h3>
<p className="text-sm text-text-tertiary mt-1">
Latest system activities / Các hoạt đng hệ thống mới nhất
</p>
</div>
{/* EN: Table Content / VI: Nội dung bảng */}
{/* EN: Mobile: Horizontal scroll / VI: Mobile: Scroll ngang */}
<div className="overflow-x-auto -mx-4 sm:mx-0">
<div className="inline-block min-w-full align-middle sm:min-w-0 px-4 sm:px-0">
{loading ? (
// EN: Loading state / VI: Trạng thái loading
<div className="p-8 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" />
<p className="mt-4 text-sm text-text-tertiary">
Loading activities... / Đang tải hoạt đng...
</p>
</div>
) : paginatedActivities.length === 0 ? (
// EN: Empty state / VI: Trạng thái trống
<div className="p-8 text-center">
<p className="text-sm text-text-tertiary">
No activities found / Không tìm thấy hoạt đng nào
</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border-primary">
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
User / Người dùng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
Action / Hành đng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
Status / Trạng thái
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
Time / Thời gian
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-tertiary uppercase tracking-wider">
Actions / Hành đng
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{paginatedActivities.map((activity) => (
<tr
key={activity.id}
className="hover:bg-bg-tertiary transition-colors duration-[150ms]"
>
{/* EN: User column / VI: Cột người dùng */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{/* EN: Avatar / VI: Avatar */}
<div className="relative flex-shrink-0 h-10 w-10">
{activity.user.avatarUrl ? (
<img
src={activity.user.avatarUrl}
alt={`Avatar of ${activity.user.name} / Avatar của ${activity.user.name}`}
className="h-10 w-10 rounded-full object-cover"
loading="lazy"
/>
) : (
<div className="h-10 w-10 rounded-full bg-chat-ai-bubble flex items-center justify-center">
<span className="text-sm font-medium text-text-primary">
{getUserInitials(activity.user.name)}
</span>
</div>
)}
</div>
<div className="ml-3">
<div className="text-sm font-medium text-text-primary">
{activity.user.name}
</div>
<div className="text-sm text-text-tertiary">
{activity.user.email}
</div>
</div>
</div>
</td>
{/* EN: Action column / VI: Cột hành động */}
<td className="px-6 py-4">
<div className="text-sm text-text-secondary">
{getActionLabel(activity.action)}
</div>
<div className="text-xs text-text-tertiary mt-1">
{activity.description}
</div>
</td>
{/* EN: Status column / VI: Cột trạng thái */}
<td className="px-6 py-4 whitespace-nowrap">
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
getStatusBadgeStyles(activity.status)
)}
>
{getStatusLabel(activity.status)}
</span>
</td>
{/* EN: Time column / VI: Cột thời gian */}
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-tertiary">
{formatRelativeTime(activity.timestamp)}
</td>
{/* EN: Quick actions column / VI: Cột quick actions */}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleQuickAction(activity.id, 'view')}
className="text-accent-primary hover:brightness-110 transition-colors duration-[150ms] min-h-[44px] px-2 py-1"
aria-label="View details / Xem chi tiết"
>
View / Xem
</button>
<span className="text-border-primary">|</span>
<button
onClick={() => handleQuickAction(activity.id, 'copy')}
className="text-accent-primary hover:brightness-110 transition-colors duration-[150ms] min-h-[44px] px-2 py-1"
aria-label="Copy ID / Sao chép ID"
>
Copy / Sao chép
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* EN: Pagination / VI: Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-border-primary flex items-center justify-between">
<div className="text-sm text-text-tertiary">
{totalItems ? (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, totalItems)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
results / kết quả
</>
) : (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, activities.length)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">
{activities.length}
</span>{' '}
results / kết quả
</>
)}
</div>
<div className="flex items-center gap-2">
{/* EN: Previous button / VI: Nút trước */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
'border border-border-primary',
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
'min-w-[44px] min-h-[44px]',
currentPage === 1 || loading
? 'opacity-50 cursor-not-allowed text-text-tertiary'
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]'
)}
aria-label="Previous page / Trang trước"
>
<span className="hidden sm:inline">Previous / Trước</span>
<span className="sm:hidden">Prev</span>
</button>
{/* EN: Page numbers / VI: Số trang */}
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => {
// EN: Show first page, last page, current page, and pages around current / VI: Hiển thị trang đầu, cuối, hiện tại và các trang xung quanh
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
) {
return (
<button
key={page}
onClick={() => handlePageChange(page)}
disabled={loading}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
'border border-border-primary',
page === currentPage
? 'bg-accent-primary text-white border-accent-primary'
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]',
loading && 'opacity-50 cursor-not-allowed'
)}
aria-label={`Page ${page} / Trang ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
);
} else if (
page === currentPage - 2 ||
page === currentPage + 2
) {
return (
<span
key={page}
className="px-2 text-sm text-text-tertiary"
>
...
</span>
);
}
return null;
}
)}
</div>
{/* EN: Next button / VI: Nút sau */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
'border border-border-primary',
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
'min-w-[44px] min-h-[44px]',
currentPage === totalPages || loading
? 'opacity-50 cursor-not-allowed text-text-tertiary'
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]'
)}
aria-label="Next page / Trang sau"
>
<span className="hidden sm:inline">Next / Sau</span>
<span className="sm:hidden">Next</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
/**
* EN: Email settings form validation schema
* VI: Schema validation cho form cài đặt email
*/
const emailSettingsSchema = z.object({
smtpHost: z.string().min(1, 'SMTP host is required / SMTP host là bắt buộc'),
smtpPort: z.number().min(1).max(65535),
smtpUser: z.string().min(1, 'SMTP user is required / SMTP user là bắt buộc'),
smtpPassword: z.string().min(1, 'SMTP password is required / SMTP password là bắt buộc'),
smtpFromEmail: z.string().email('Invalid email format / Định dạng email không hợp lệ'),
smtpFromName: z.string().min(1, 'From name is required / Tên người gửi là bắt buộc'),
});
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
/**
* EN: EmailSettings component - Form for email/SMTP configuration
* VI: Component EmailSettings - Form cho cấu hình email/SMTP
*
* Features:
* - SMTP configuration (host, port, user, password)
* - From email and name
* - Email templates management
* - Test email function
*
* Tính năng:
* - Cấu hình SMTP (host, port, user, password)
* - Email và tên người gửi
* - Quản lý email templates
* - Chức năng test email
*/
export function EmailSettings() {
const [isSaving, setIsSaving] = React.useState(false);
const [isTesting, setIsTesting] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const [testResult, setTestResult] = React.useState<'success' | 'error' | null>(null);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
} = useForm<EmailSettingsFormData>({
resolver: zodResolver(emailSettingsSchema),
defaultValues: {
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpUser: '',
smtpPassword: '',
smtpFromEmail: 'noreply@goodgo.vn',
smtpFromName: 'GoodGo Platform',
},
});
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: EmailSettingsFormData) => {
setIsSaving(true);
setSaveSuccess(false);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 500));
reset(data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
console.error('Failed to save email settings / Không thể lưu cài đặt email:', error);
} finally {
setIsSaving(false);
}
};
// EN: Handle test email / VI: Xử lý test email
const handleTestEmail = async () => {
setIsTesting(true);
setTestResult(null);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 1000));
setTestResult('success');
setTimeout(() => setTestResult(null), 3000);
} catch (error) {
setTestResult('error');
setTimeout(() => setTestResult(null), 3000);
} finally {
setIsTesting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Email Settings / Cài đt Email</CardTitle>
<CardDescription>
Configure SMTP settings for sending emails / Cấu hình cài đt SMTP đ gửi email
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: SMTP Configuration / VI: Cấu hình SMTP */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
SMTP Configuration / Cấu hình SMTP
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="SMTP Host / SMTP Host"
placeholder="smtp.example.com"
{...register('smtpHost')}
errorMessage={errors.smtpHost?.message}
validationState={errors.smtpHost ? 'error' : 'default'}
/>
<Input
label="SMTP Port / SMTP Port"
type="number"
placeholder="587"
{...register('smtpPort', { valueAsNumber: true })}
errorMessage={errors.smtpPort?.message}
validationState={errors.smtpPort ? 'error' : 'default'}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="SMTP User / SMTP User"
placeholder="your-email@example.com"
{...register('smtpUser')}
errorMessage={errors.smtpUser?.message}
validationState={errors.smtpUser ? 'error' : 'default'}
/>
<Input
label="SMTP Password / SMTP Password"
type="password"
placeholder="Enter SMTP password / Nhập mật khẩu SMTP"
{...register('smtpPassword')}
errorMessage={errors.smtpPassword?.message}
validationState={errors.smtpPassword ? 'error' : 'default'}
/>
</div>
</div>
{/* EN: From Settings / VI: Cài đặt người gửi */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
From Settings / Cài đt người gửi
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="From Email / Email người gửi"
type="email"
placeholder="noreply@goodgo.vn"
{...register('smtpFromEmail')}
errorMessage={errors.smtpFromEmail?.message}
validationState={errors.smtpFromEmail ? 'error' : 'default'}
/>
<Input
label="From Name / Tên người gửi"
placeholder="GoodGo Platform"
{...register('smtpFromName')}
errorMessage={errors.smtpFromName?.message}
validationState={errors.smtpFromName ? 'error' : 'default'}
/>
</div>
</div>
{/* EN: Test email section / VI: Phần test email */}
<div className="p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-text-primary">
Test Email / Test Email
</h4>
<p className="text-sm text-text-tertiary mt-1">
Send a test email to verify SMTP configuration / Gửi email test đ xác minh cấu hình SMTP
</p>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleTestEmail}
loading={isTesting}
>
Send Test Email / Gửi email test
</Button>
</div>
{testResult === 'success' && (
<p className="text-sm text-accent-success mt-2">
Test email sent successfully! / Email test đã đưc gi thành công!
</p>
)}
{testResult === 'error' && (
<p className="text-sm text-accent-error mt-2">
Failed to send test email / Không thể gửi email test
</p>
)}
</div>
{/* EN: Success message / VI: Thông báo thành công */}
{saveSuccess && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Settings saved successfully / Đã lưu cài đt thành công
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
Save Changes / Lưu thay đi
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
/**
* EN: General settings form validation schema
* VI: Schema validation cho form cài đặt chung
*/
const generalSettingsSchema = z.object({
siteName: z.string().min(1, 'Site name is required / Tên site là bắt buộc'),
defaultLanguage: z.enum(['en', 'vi'], {
required_error: 'Language is required / Ngôn ngữ là bắt buộc',
}),
timezone: z.string().min(1, 'Timezone is required / Múi giờ là bắt buộc'),
maintenanceMode: z.boolean(),
});
type GeneralSettingsFormData = z.infer<typeof generalSettingsSchema>;
/**
* EN: GeneralSettings component - Form for general system settings
* VI: Component GeneralSettings - Form cho cài đặt hệ thống chung
*
* Features:
* - Site name input
* - Logo upload
* - Default language selection
* - Timezone selection
* - Maintenance mode toggle
*
* Tính năng:
* - Input tên site
* - Upload logo
* - Chọn ngôn ngữ mặc định
* - Chọn múi giờ
* - Toggle chế độ bảo trì
*/
export function GeneralSettings() {
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
setValue,
} = useForm<GeneralSettingsFormData>({
resolver: zodResolver(generalSettingsSchema),
defaultValues: {
siteName: 'GoodGo Platform',
defaultLanguage: 'en',
timezone: 'UTC',
maintenanceMode: false,
},
});
const maintenanceMode = watch('maintenanceMode');
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: GeneralSettingsFormData) => {
setIsSaving(true);
setSaveSuccess(false);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 500));
reset(data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
console.error('Failed to save settings / Không thể lưu cài đặt:', error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>General Settings / Cài đt chung</CardTitle>
<CardDescription>
Configure general system settings / Cấu hình cài đt hệ thống chung
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: Site name / VI: Tên site */}
<Input
label="Site Name / Tên site"
placeholder="Enter site name / Nhập tên site"
{...register('siteName')}
errorMessage={errors.siteName?.message}
validationState={errors.siteName ? 'error' : 'default'}
/>
{/* EN: Logo upload / VI: Upload logo */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Site Logo / Logo site
</label>
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-lg bg-bg-tertiary border border-border-primary flex items-center justify-center">
<span className="text-xs text-text-tertiary">Logo</span>
</div>
<div>
<Button variant="secondary" size="sm" type="button">
Upload Logo / Tải logo
</Button>
<p className="text-xs text-text-tertiary mt-1">
Recommended: 200x200px, PNG or SVG / Khuyến nghị: 200x200px, PNG hoặc SVG
</p>
</div>
</div>
</div>
{/* EN: Default language / VI: Ngôn ngữ mặc định */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Default Language / Ngôn ngữ mặc đnh
</label>
<select
{...register('defaultLanguage')}
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2"
>
<option value="en">English</option>
<option value="vi">Tiếng Việt</option>
</select>
</div>
{/* EN: Timezone / VI: Múi giờ */}
<Input
label="Timezone / Múi giờ"
placeholder="UTC"
{...register('timezone')}
errorMessage={errors.timezone?.message}
validationState={errors.timezone ? 'error' : 'default'}
helperText="Server timezone / Múi giờ server"
/>
{/* EN: Maintenance mode / VI: Chế độ bảo trì */}
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex-1">
<label
htmlFor="maintenance-mode"
className="text-sm font-medium text-text-primary cursor-pointer"
>
Maintenance Mode / Chế đ bảo trì
</label>
<p className="text-sm text-text-tertiary mt-1">
Enable maintenance mode to restrict access / Bật chế đ bảo trì đ hạn chế truy cập
</p>
</div>
<Switch
id="maintenance-mode"
checked={maintenanceMode}
onCheckedChange={(checked) => setValue('maintenanceMode', checked)}
/>
</div>
{/* EN: Success message / VI: Thông báo thành công */}
{saveSuccess && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Settings saved successfully / Đã lưu cài đt thành công
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
Save Changes / Lưu thay đi
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
/**
* EN: Security settings form validation schema
* VI: Schema validation cho form cài đặt bảo mật
*/
const securitySettingsSchema = z.object({
minPasswordLength: z.number().min(8).max(128),
requireUppercase: z.boolean(),
requireLowercase: z.boolean(),
requireNumbers: z.boolean(),
requireSpecialChars: z.boolean(),
sessionTimeout: z.number().min(5).max(1440), // minutes
maxLoginAttempts: z.number().min(3).max(10),
lockoutDuration: z.number().min(5).max(60), // minutes
enableRateLimiting: z.boolean(),
rateLimitRequests: z.number().min(10).max(10000),
rateLimitWindow: z.number().min(1).max(60), // minutes
});
type SecuritySettingsFormData = z.infer<typeof securitySettingsSchema>;
/**
* EN: SecuritySettings component - Form for security settings
* VI: Component SecuritySettings - Form cho cài đặt bảo mật
*
* Features:
* - Password policy configuration
* - Session timeout
* - IP whitelist/blacklist
* - Rate limiting
* - CORS settings
*
* Tính năng:
* - Cấu hình chính sách mật khẩu
* - Timeout session
* - IP whitelist/blacklist
* - Rate limiting
* - Cài đặt CORS
*/
export function SecuritySettings() {
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
setValue,
} = useForm<SecuritySettingsFormData>({
resolver: zodResolver(securitySettingsSchema),
defaultValues: {
minPasswordLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: true,
sessionTimeout: 30,
maxLoginAttempts: 5,
lockoutDuration: 15,
enableRateLimiting: true,
rateLimitRequests: 100,
rateLimitWindow: 1,
},
});
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: SecuritySettingsFormData) => {
setIsSaving(true);
setSaveSuccess(false);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 500));
reset(data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
console.error('Failed to save security settings / Không thể lưu cài đặt bảo mật:', error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Security Settings / Cài đt bảo mật</CardTitle>
<CardDescription>
Configure security policies and restrictions / Cấu hình chính sách hạn chế bảo mật
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: Password Policy / VI: Chính sách mật khẩu */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
Password Policy / Chính sách mật khẩu
</h3>
<Input
label="Minimum Password Length / Độ dài mật khẩu tối thiểu"
type="number"
{...register('minPasswordLength', { valueAsNumber: true })}
errorMessage={errors.minPasswordLength?.message}
validationState={errors.minPasswordLength ? 'error' : 'default'}
helperText="Minimum 8 characters / Tối thiểu 8 ký tự"
/>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-uppercase" className="text-sm font-medium text-text-primary cursor-pointer">
Require Uppercase / Yêu cầu chữ hoa
</label>
<Switch
id="require-uppercase"
checked={watch('requireUppercase')}
onCheckedChange={(checked) => setValue('requireUppercase', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-lowercase" className="text-sm font-medium text-text-primary cursor-pointer">
Require Lowercase / Yêu cầu chữ thường
</label>
<Switch
id="require-lowercase"
checked={watch('requireLowercase')}
onCheckedChange={(checked) => setValue('requireLowercase', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-numbers" className="text-sm font-medium text-text-primary cursor-pointer">
Require Numbers / Yêu cầu số
</label>
<Switch
id="require-numbers"
checked={watch('requireNumbers')}
onCheckedChange={(checked) => setValue('requireNumbers', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-special" className="text-sm font-medium text-text-primary cursor-pointer">
Require Special Characters / Yêu cầu tự đc biệt
</label>
<Switch
id="require-special"
checked={watch('requireSpecialChars')}
onCheckedChange={(checked) => setValue('requireSpecialChars', checked)}
/>
</div>
</div>
</div>
{/* EN: Session Settings / VI: Cài đặt session */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
Session Settings / Cài đt session
</h3>
<Input
label="Session Timeout (minutes) / Timeout session (phút)"
type="number"
{...register('sessionTimeout', { valueAsNumber: true })}
errorMessage={errors.sessionTimeout?.message}
validationState={errors.sessionTimeout ? 'error' : 'default'}
/>
<Input
label="Max Login Attempts / Số lần đăng nhập tối đa"
type="number"
{...register('maxLoginAttempts', { valueAsNumber: true })}
errorMessage={errors.maxLoginAttempts?.message}
validationState={errors.maxLoginAttempts ? 'error' : 'default'}
/>
<Input
label="Lockout Duration (minutes) / Thời gian khóa (phút)"
type="number"
{...register('lockoutDuration', { valueAsNumber: true })}
errorMessage={errors.lockoutDuration?.message}
validationState={errors.lockoutDuration ? 'error' : 'default'}
/>
</div>
{/* EN: Rate Limiting / VI: Rate limiting */}
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex-1">
<label htmlFor="enable-rate-limiting" className="text-sm font-medium text-text-primary cursor-pointer">
Enable Rate Limiting / Bật rate limiting
</label>
<p className="text-sm text-text-tertiary mt-1">
Limit requests per time window / Giới hạn requests mỗi cửa sổ thời gian
</p>
</div>
<Switch
id="enable-rate-limiting"
checked={watch('enableRateLimiting')}
onCheckedChange={(checked) => setValue('enableRateLimiting', checked)}
/>
</div>
{watch('enableRateLimiting') && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="Max Requests / Requests tối đa"
type="number"
{...register('rateLimitRequests', { valueAsNumber: true })}
errorMessage={errors.rateLimitRequests?.message}
validationState={errors.rateLimitRequests ? 'error' : 'default'}
/>
<Input
label="Time Window (minutes) / Cửa sổ thời gian (phút)"
type="number"
{...register('rateLimitWindow', { valueAsNumber: true })}
errorMessage={errors.rateLimitWindow?.message}
validationState={errors.rateLimitWindow ? 'error' : 'default'}
/>
</div>
)}
</div>
{/* EN: IP Management placeholder / VI: Placeholder quản lý IP */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
IP Management / Quản IP
</h3>
<p className="text-sm text-text-tertiary">
IP whitelist/blacklist management will be implemented here / Quản IP whitelist/blacklist sẽ đưc implement đây
</p>
</div>
{/* EN: CORS Settings placeholder / VI: Placeholder cài đặt CORS */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
CORS Settings / Cài đt CORS
</h3>
<p className="text-sm text-text-tertiary">
CORS configuration will be implemented here / Cấu hình CORS sẽ đưc implement đây
</p>
</div>
{/* EN: Success message / VI: Thông báo thành công */}
{saveSuccess && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Settings saved successfully / Đã lưu cài đt thành công
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
Save Changes / Lưu thay đi
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,343 @@
'use client';
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Edit, Trash2, Ban, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* EN: User interface
* VI: Interface cho User
*/
export interface User {
id: string;
email: string;
firstName?: string;
lastName?: string;
role: string;
status: 'active' | 'inactive' | 'banned';
avatarUrl?: string;
createdAt: string;
lastLoginAt?: string;
}
/**
* EN: Activity log entry
* VI: Entry log hoạt động
*/
export interface ActivityLog {
id: string;
action: string;
description: string;
timestamp: string;
}
/**
* EN: Message history entry
* VI: Entry lịch sử tin nhắn
*/
export interface MessageHistory {
id: string;
content: string;
conversationId: string;
timestamp: string;
}
/**
* EN: UserDetailsModal component props
* VI: Props của component UserDetailsModal
*/
export interface UserDetailsModalProps {
/**
* EN: User data / VI: Dữ liệu người dùng
*/
user: User | null;
/**
* EN: Modal open state / VI: Trạng thái mở modal
*/
open: boolean;
/**
* EN: Callback when modal closes / VI: Callback khi modal đóng
*/
onOpenChange: (open: boolean) => void;
/**
* EN: Activity logs / VI: Log hoạt động
*/
activityLogs?: ActivityLog[];
/**
* EN: Message history / VI: Lịch sử tin nhắn
*/
messageHistory?: MessageHistory[];
/**
* EN: Callback when edit is clicked / VI: Callback khi edit được click
*/
onEdit?: (user: User) => void;
/**
* EN: Callback when delete is clicked / VI: Callback khi delete được click
*/
onDelete?: (userId: string) => void;
/**
* EN: Callback when deactivate is clicked / VI: Callback khi deactivate được click
*/
onDeactivate?: (userId: string) => void;
}
/**
* EN: UserDetailsModal component - Modal showing user details, activity timeline, and message history
* VI: Component UserDetailsModal - Modal hiển thị chi tiết người dùng, timeline hoạt động và lịch sử tin nhắn
*
* Features:
* - User profile information
* - Activity timeline
* - Message history
* - Edit, Delete, Deactivate actions
*
* Tính năng:
* - Thông tin profile người dùng
* - Timeline hoạt động
* - Lịch sử tin nhắn
* - Các hành động Edit, Delete, Deactivate
*/
export function UserDetailsModal({
user,
open,
onOpenChange,
activityLogs = [],
messageHistory = [],
onEdit,
onDelete,
onDeactivate,
}: UserDetailsModalProps) {
if (!user) return null;
// EN: Get user initials / VI: Lấy initials của user
const getUserInitials = () => {
if (user.firstName && user.lastName) {
return `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`.toUpperCase();
}
return user.email.charAt(0).toUpperCase();
};
// EN: Format timestamp / VI: Format timestamp
const formatTimestamp = (date: string) => {
return new Date(date).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// EN: Get status badge color / VI: Lấy màu badge trạng thái
const getStatusColor = () => {
switch (user.status) {
case 'active':
return 'bg-accent-success/20 text-accent-success border-accent-success/30';
case 'inactive':
return 'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30';
case 'banned':
return 'bg-accent-error/20 text-accent-error border-accent-error/30';
default:
return 'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>User Details / Chi tiết người dùng</DialogTitle>
<DialogDescription>
View and manage user information / Xem quản thông tin người dùng
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="profile" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="profile">Profile / Hồ </TabsTrigger>
<TabsTrigger value="activity">Activity / Hoạt đng</TabsTrigger>
<TabsTrigger value="messages">Messages / Tin nhắn</TabsTrigger>
</TabsList>
{/* EN: Profile tab / VI: Tab Profile */}
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar size="lg" className="h-20 w-20">
{user.avatarUrl && (
<AvatarImage src={user.avatarUrl} alt={user.email} />
)}
<AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="text-xl font-semibold text-text-primary">
{user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.email}
</h3>
<p className="text-sm text-text-tertiary mt-1">{user.email}</p>
<div className="mt-2">
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
getStatusColor()
)}
>
{user.status === 'active' && (
<CheckCircle2 className="h-3 w-3 mr-1" />
)}
{user.status === 'banned' && (
<Ban className="h-3 w-3 mr-1" />
)}
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-text-tertiary mb-1">Role / Vai trò</p>
<p className="text-sm font-medium text-text-primary">{user.role}</p>
</div>
<div>
<p className="text-xs text-text-tertiary mb-1">
Created / Tạo
</p>
<p className="text-sm font-medium text-text-primary">
{formatTimestamp(user.createdAt)}
</p>
</div>
{user.lastLoginAt && (
<div>
<p className="text-xs text-text-tertiary mb-1">
Last Login / Lần đăng nhập cuối
</p>
<p className="text-sm font-medium text-text-primary">
{formatTimestamp(user.lastLoginAt)}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* EN: Action buttons / VI: Nút hành động */}
<div className="flex items-center justify-end gap-2">
{onEdit && (
<Button variant="secondary" size="sm" onClick={() => onEdit(user)}>
<Edit className="h-4 w-4 mr-2" />
Edit / Chỉnh sửa
</Button>
)}
{onDeactivate && (
<Button
variant="secondary"
size="sm"
onClick={() => onDeactivate(user.id)}
>
<Ban className="h-4 w-4 mr-2" />
{user.status === 'active' ? 'Deactivate / Vô hiệu hóa' : 'Activate / Kích hoạt'}
</Button>
)}
{onDelete && (
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm('Are you sure you want to delete this user? / Bạn có chắc chắn muốn xóa người dùng này?')) {
onDelete(user.id);
}
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete / Xóa
</Button>
)}
</div>
</TabsContent>
{/* EN: Activity tab / VI: Tab Activity */}
<TabsContent value="activity" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Activity Timeline / Timeline hoạt đng</CardTitle>
</CardHeader>
<CardContent>
{activityLogs.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
No activity logs / Không log hoạt đng
</p>
) : (
<div className="space-y-4">
{activityLogs.map((log) => (
<div
key={log.id}
className="flex items-start gap-4 pb-4 border-b border-border-primary last:border-0"
>
<div className="flex-1">
<p className="text-sm font-medium text-text-primary">
{log.action}
</p>
<p className="text-sm text-text-secondary mt-1">
{log.description}
</p>
<p className="text-xs text-text-tertiary mt-1">
{formatTimestamp(log.timestamp)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* EN: Messages tab / VI: Tab Messages */}
<TabsContent value="messages" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Message History / Lịch sử tin nhắn</CardTitle>
</CardHeader>
<CardContent>
{messageHistory.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
No messages / Không tin nhắn
</p>
) : (
<div className="space-y-4">
{messageHistory.map((message) => (
<div
key={message.id}
className="p-4 rounded-lg bg-bg-tertiary border border-border-primary"
>
<p className="text-sm text-text-primary">{message.content}</p>
<p className="text-xs text-text-tertiary mt-2">
{formatTimestamp(message.timestamp)}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useTheme } from '../contexts/theme-context';
/**
* EN: Theme toggle button component
* VI: Component nút chuyển đổi theme
*/
export function ThemeToggle() {
const { resolvedTheme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
aria-label={resolvedTheme === 'dark' ? 'Switch to light mode / Chuyển sang chế độ sáng' : 'Switch to dark mode / Chuyển sang chế độ tối'}
type="button"
>
{resolvedTheme === 'dark' ? (
// EN: Sun icon for light mode / VI: Icon mặt trời cho chế độ sáng
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
// EN: Moon icon for dark mode / VI: Icon mặt trăng cho chế độ tối
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/avatar.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/button.stories.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/button.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/card.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/dialog.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/dropdown-menu.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/input.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/select.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/switch.tsx

View File

@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
/**
* EN: Tabs component - Tab navigation using Radix UI
* VI: Component Tabs - Điều hướng tab sử dụng Radix UI
*/
const Tabs = TabsPrimitive.Root;
/**
* EN: TabsList component - Container for tab triggers
* VI: Component TabsList - Container cho tab triggers
*/
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-lg bg-bg-tertiary p-1 text-text-secondary',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
/**
* EN: TabsTrigger component - Individual tab button
* VI: Component TabsTrigger - Nút tab riêng lẻ
*/
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-[150ms]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-bg-secondary data-[state=active]:text-text-primary data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
/**
* EN: TabsContent component - Content panel for each tab
* VI: Component TabsContent - Panel nội dung cho mỗi tab
*/
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };