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:
230
apps/web-admin/src/components/admin/analytics-card.tsx
Normal file
230
apps/web-admin/src/components/admin/analytics-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/web-admin/src/components/admin/charts/revenue-chart.tsx
Normal file
171
apps/web-admin/src/components/admin/charts/revenue-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
apps/web-admin/src/components/admin/charts/user-growth-chart.tsx
Normal file
145
apps/web-admin/src/components/admin/charts/user-growth-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
491
apps/web-admin/src/components/admin/data-table.tsx
Normal file
491
apps/web-admin/src/components/admin/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
551
apps/web-admin/src/components/admin/recent-activity-table.tsx
Normal file
551
apps/web-admin/src/components/admin/recent-activity-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
apps/web-admin/src/components/admin/settings/email-settings.tsx
Normal file
250
apps/web-admin/src/components/admin/settings/email-settings.tsx
Normal 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 gửi 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 và 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 ký 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 lý IP
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
IP whitelist/blacklist management will be implemented here / Quản lý 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>
|
||||
);
|
||||
}
|
||||
343
apps/web-admin/src/components/admin/user-details-modal.tsx
Normal file
343
apps/web-admin/src/components/admin/user-details-modal.tsx
Normal 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 và quản lý 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ồ sơ</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 có 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 có 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>
|
||||
);
|
||||
}
|
||||
62
apps/web-admin/src/components/theme-toggle.tsx
Normal file
62
apps/web-admin/src/components/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/web-admin/src/components/ui/avatar.tsx
Symbolic link
1
apps/web-admin/src/components/ui/avatar.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/avatar.tsx
|
||||
1
apps/web-admin/src/components/ui/button.stories.tsx
Symbolic link
1
apps/web-admin/src/components/ui/button.stories.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/button.stories.tsx
|
||||
1
apps/web-admin/src/components/ui/button.tsx
Symbolic link
1
apps/web-admin/src/components/ui/button.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/button.tsx
|
||||
1
apps/web-admin/src/components/ui/card.tsx
Symbolic link
1
apps/web-admin/src/components/ui/card.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/card.tsx
|
||||
1
apps/web-admin/src/components/ui/dialog.tsx
Symbolic link
1
apps/web-admin/src/components/ui/dialog.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/dialog.tsx
|
||||
1
apps/web-admin/src/components/ui/dropdown-menu.tsx
Symbolic link
1
apps/web-admin/src/components/ui/dropdown-menu.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/dropdown-menu.tsx
|
||||
1
apps/web-admin/src/components/ui/input.tsx
Symbolic link
1
apps/web-admin/src/components/ui/input.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/input.tsx
|
||||
1
apps/web-admin/src/components/ui/select.tsx
Symbolic link
1
apps/web-admin/src/components/ui/select.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/select.tsx
|
||||
1
apps/web-admin/src/components/ui/switch.tsx
Symbolic link
1
apps/web-admin/src/components/ui/switch.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/switch.tsx
|
||||
73
apps/web-admin/src/components/ui/tabs.tsx
Normal file
73
apps/web-admin/src/components/ui/tabs.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user