- Fix Next.js build failure: remove duplicate route at (dashboard)/listings/[id] that conflicted with (public)/listings/[id] (same URL path in two route groups) - Fix 772 ESLint errors: auto-fix import ordering (import-x/order), remove unused imports/variables, convert empty interfaces to type aliases, replace require() with ESM imports, fix consistent-type-imports violations - Add CLAUDE.md for developer onboarding documentation - All checks pass: 0 lint errors, typecheck clean, 230 tests passing, build success Co-Authored-By: Paperclip <noreply@paperclip.ing>
224 lines
6.7 KiB
TypeScript
224 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
Users,
|
|
Home,
|
|
ClipboardCheck,
|
|
Clock,
|
|
UserCheck,
|
|
ShieldCheck,
|
|
ArrowUpRight,
|
|
ArrowDownRight,
|
|
TrendingUp,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { adminApi, type DashboardStats, type RevenueStatsItem } from '@/lib/admin-api';
|
|
|
|
interface StatCardProps {
|
|
title: string;
|
|
value: number;
|
|
icon: React.ElementType;
|
|
description?: string;
|
|
trend?: 'up' | 'down';
|
|
trendValue?: string;
|
|
}
|
|
|
|
function StatCard({ title, value, icon: Icon, description, trend, trendValue }: StatCardProps) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{value.toLocaleString('vi-VN')}</div>
|
|
{(description || trendValue) && (
|
|
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
|
{trend === 'up' && <ArrowUpRight className="h-3 w-3 text-green-600" />}
|
|
{trend === 'down' && <ArrowDownRight className="h-3 w-3 text-red-600" />}
|
|
{trendValue && (
|
|
<span className={trend === 'up' ? 'text-green-600' : trend === 'down' ? 'text-red-600' : ''}>
|
|
{trendValue}
|
|
</span>
|
|
)}
|
|
{description && <span>{description}</span>}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function RevenueChart({ data }: { data: RevenueStatsItem[] }) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
|
Chưa có dữ liệu doanh thu
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const maxRevenue = Math.max(...data.map((d) => d.totalRevenue), 1);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{data.map((item) => {
|
|
const pct = (item.totalRevenue / maxRevenue) * 100;
|
|
return (
|
|
<div key={item.period} className="space-y-1">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{item.period}</span>
|
|
<span className="font-medium">
|
|
{item.totalRevenue.toLocaleString('vi-VN')} VND
|
|
</span>
|
|
</div>
|
|
<div className="h-2 w-full rounded-full bg-muted">
|
|
<div
|
|
className="h-2 rounded-full bg-primary transition-all"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
|
<span>Subscription: {item.subscriptionRevenue.toLocaleString('vi-VN')}</span>
|
|
<span>Listing fee: {item.listingFeeRevenue.toLocaleString('vi-VN')}</span>
|
|
<span>{item.transactionCount} GD</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminDashboardPage() {
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [revenue, setRevenue] = useState<RevenueStatsItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const endDate = new Date().toISOString().split('T')[0]!;
|
|
const startDate = new Date(Date.now() - 180 * 86400000).toISOString().split('T')[0]!;
|
|
|
|
const [statsData, revenueData] = await Promise.all([
|
|
adminApi.getDashboardStats(),
|
|
adminApi.getRevenueStats(startDate, endDate, 'month'),
|
|
]);
|
|
setStats(statsData);
|
|
setRevenue(revenueData);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Không thể tải dữ liệu');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-3">
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
|
Thử lại
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!stats) return null;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Tổng quan hệ thống GoodGo
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
Làm mới
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Stats grid */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard
|
|
title="Tổng người dùng"
|
|
value={stats.totalUsers}
|
|
icon={Users}
|
|
trend="up"
|
|
trendValue={`+${stats.newUsersLast30Days}`}
|
|
description="trong 30 ngày"
|
|
/>
|
|
<StatCard
|
|
title="Tổng tin đăng"
|
|
value={stats.totalListings}
|
|
icon={Home}
|
|
trend="up"
|
|
trendValue={`+${stats.newListingsLast30Days}`}
|
|
description="trong 30 ngày"
|
|
/>
|
|
<StatCard
|
|
title="Tin đang hoạt động"
|
|
value={stats.activeListings}
|
|
icon={ClipboardCheck}
|
|
description="đang hiển thị"
|
|
/>
|
|
<StatCard
|
|
title="Chờ kiểm duyệt"
|
|
value={stats.pendingModerationCount}
|
|
icon={Clock}
|
|
description="tin đang chờ duyệt"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<StatCard
|
|
title="Tổng đại lý"
|
|
value={stats.totalAgents}
|
|
icon={UserCheck}
|
|
/>
|
|
<StatCard
|
|
title="Đại lý đã xác minh"
|
|
value={stats.verifiedAgents}
|
|
icon={ShieldCheck}
|
|
/>
|
|
<StatCard
|
|
title="Tổng giao dịch"
|
|
value={stats.totalTransactions}
|
|
icon={TrendingUp}
|
|
/>
|
|
</div>
|
|
|
|
{/* Revenue chart */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Doanh thu 6 tháng gần nhất</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RevenueChart data={revenue} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|