- Add is_banner, soldout fields to Car model
- Add banner toggle API (POST /hero-banners/admin/toggle/{car_id})
- Add soldout APIs (POST/DELETE /cars/{car_id}/soldout)
- Add nightly soldout checker in agent (runs at 3:00 AM)
- Update Local Cars UI with banner checkbox and status column
- Remove hero-banners admin page (functionality moved to Cars page)
- Banner cars sorted to top with purple background
- Soldout cars displayed with gray overlay
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import Link from 'next/link';
|
||
import {
|
||
dashboardApi,
|
||
DashboardStats,
|
||
RevenueStats,
|
||
ChartData,
|
||
RecentActivity,
|
||
TopDealer,
|
||
PendingActions,
|
||
} from '@/lib/api';
|
||
|
||
export default function AdminDashboard() {
|
||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||
const [revenue, setRevenue] = useState<RevenueStats | null>(null);
|
||
const [userChart, setUserChart] = useState<ChartData | null>(null);
|
||
const [requestChart, setRequestChart] = useState<ChartData | null>(null);
|
||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([]);
|
||
const [topDealers, setTopDealers] = useState<TopDealer[]>([]);
|
||
const [pendingActions, setPendingActions] = useState<PendingActions | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [chartDays, setChartDays] = useState(14);
|
||
|
||
useEffect(() => {
|
||
loadDashboardData();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadChartData();
|
||
}, [chartDays]);
|
||
|
||
const loadDashboardData = async () => {
|
||
try {
|
||
const [statsData, revenueData, activitiesData, dealersData, pendingData] = await Promise.all([
|
||
dashboardApi.getStats(),
|
||
dashboardApi.getRevenue(),
|
||
dashboardApi.getRecentActivities(10),
|
||
dashboardApi.getTopDealers(5),
|
||
dashboardApi.getPendingActions(),
|
||
]);
|
||
setStats(statsData);
|
||
setRevenue(revenueData);
|
||
setRecentActivities(activitiesData);
|
||
setTopDealers(dealersData);
|
||
setPendingActions(pendingData);
|
||
} catch (error) {
|
||
console.error('Failed to load dashboard data:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadChartData = async () => {
|
||
try {
|
||
const [userData, requestData] = await Promise.all([
|
||
dashboardApi.getUserChart(chartDays),
|
||
dashboardApi.getRequestChart(chartDays),
|
||
]);
|
||
setUserChart(userData);
|
||
setRequestChart(requestData);
|
||
} catch (error) {
|
||
console.error('Failed to load chart data:', error);
|
||
}
|
||
};
|
||
|
||
const formatNumber = (num: number) => {
|
||
return new Intl.NumberFormat('ko-KR').format(num);
|
||
};
|
||
|
||
const formatCurrency = (num: number) => {
|
||
return new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(num);
|
||
};
|
||
|
||
const getActivityIcon = (icon: string) => {
|
||
switch (icon) {
|
||
case 'user': return '👤';
|
||
case 'car': return '🚗';
|
||
case 'message': return '💬';
|
||
case 'badge': return '🎫';
|
||
case 'wallet': return '💰';
|
||
default: return '📌';
|
||
}
|
||
};
|
||
|
||
const getTimeAgo = (time: string) => {
|
||
if (!time) return '';
|
||
const now = new Date();
|
||
const then = new Date(time);
|
||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000);
|
||
|
||
if (diff < 60) return `${diff}초 전`;
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`;
|
||
return `${Math.floor(diff / 86400)}일 전`;
|
||
};
|
||
|
||
const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 100 }: { data: ChartData | null; color?: string; height?: number }) => {
|
||
if (!data || data.values.length === 0) return <div className="text-gray-400 text-center py-4">No data</div>;
|
||
|
||
const maxValue = Math.max(...data.values, 1);
|
||
|
||
return (
|
||
<div className="flex items-end gap-1" style={{ height }}>
|
||
{data.values.map((value, index) => (
|
||
<div key={index} className="flex-1 flex flex-col items-center group">
|
||
<div className="hidden group-hover:block absolute -mt-6 bg-gray-800 text-white text-xs px-2 py-1 rounded">
|
||
{value}
|
||
</div>
|
||
<div
|
||
className={`w-full ${color} rounded-t transition-all hover:opacity-80`}
|
||
style={{ height: `${(value / maxValue) * 100}%`, minHeight: value > 0 ? '4px' : '0' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold text-gray-800">Dashboard</h1>
|
||
<button
|
||
onClick={loadDashboardData}
|
||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm flex items-center gap-2"
|
||
>
|
||
<span>Refresh</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Pending Actions Alert */}
|
||
{pendingActions && pendingActions.total_pending > 0 && (
|
||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||
<div className="flex items-center gap-2 text-amber-700 font-medium mb-3">
|
||
<span className="text-xl">⚠️</span>
|
||
<span>Pending Actions Required ({pendingActions.total_pending})</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
{pendingActions.pending_requests > 0 && (
|
||
<Link href="/admin/vehicle-requests" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||
<span className="text-lg">🚗</span>
|
||
<span className="text-sm">Vehicle Requests: {pendingActions.pending_requests}</span>
|
||
</Link>
|
||
)}
|
||
{pendingActions.pending_inquiries > 0 && (
|
||
<Link href="/admin/inquiries" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||
<span className="text-lg">💬</span>
|
||
<span className="text-sm">Inquiries: {pendingActions.pending_inquiries}</span>
|
||
</Link>
|
||
)}
|
||
{pendingActions.pending_dealer_applications > 0 && (
|
||
<Link href="/admin/dealers" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||
<span className="text-lg">🎫</span>
|
||
<span className="text-sm">Dealer Apps: {pendingActions.pending_dealer_applications}</span>
|
||
</Link>
|
||
)}
|
||
{pendingActions.pending_withdrawals > 0 && (
|
||
<Link href="/admin/withdrawals" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||
<span className="text-lg">💰</span>
|
||
<span className="text-sm">Withdrawals: {pendingActions.pending_withdrawals}</span>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats Grid */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<Link href="/admin/users" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-gray-500 text-sm">Total Users</p>
|
||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_users || 0)}</p>
|
||
<p className="text-xs text-green-600 mt-1">+{stats?.new_users_this_week || 0} this week</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl">
|
||
👥
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/admin/dealers" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-gray-500 text-sm">Active Dealers</p>
|
||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_dealers || 0)}</p>
|
||
{(stats?.pending_dealer_applications || 0) > 0 && (
|
||
<p className="text-xs text-amber-600 mt-1">{stats?.pending_dealer_applications} pending</p>
|
||
)}
|
||
</div>
|
||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl">
|
||
🎫
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/admin/vehicle-requests" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-gray-500 text-sm">Vehicle Requests</p>
|
||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_vehicle_requests || 0)}</p>
|
||
{(stats?.pending_requests || 0) > 0 && (
|
||
<p className="text-xs text-amber-600 mt-1">{stats?.pending_requests} pending</p>
|
||
)}
|
||
</div>
|
||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl">
|
||
🚗
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/admin/purchased" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-gray-500 text-sm">Purchased Vehicles</p>
|
||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_purchased_vehicles || 0)}</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center text-2xl">
|
||
📦
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Revenue & CC Stats */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-5 text-white">
|
||
<p className="text-blue-100 text-sm">Total CC Charged</p>
|
||
<p className="text-3xl font-bold mt-1">{formatNumber(stats?.total_cc_charged || 0)} CC</p>
|
||
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
||
<span>Revenue this month:</span>
|
||
<span className="font-semibold text-white">{formatCurrency(revenue?.revenue_this_month || 0)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-5 text-white">
|
||
<p className="text-green-100 text-sm">Share Rewards</p>
|
||
<p className="text-3xl font-bold mt-1">{formatNumber(stats?.total_shares || 0)}</p>
|
||
<div className="mt-2 flex items-center gap-2 text-sm text-green-100">
|
||
<span>Purchased:</span>
|
||
<span className="font-semibold text-white">{formatNumber(stats?.purchased_shares || 0)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Link href="/admin/withdrawals" className="bg-gradient-to-r from-purple-500 to-purple-600 rounded-xl p-5 text-white hover:opacity-90">
|
||
<p className="text-purple-100 text-sm">Total Withdrawals</p>
|
||
<p className="text-3xl font-bold mt-1">{formatCurrency(stats?.total_withdrawal_amount || 0)}</p>
|
||
{(stats?.pending_withdrawals || 0) > 0 && (
|
||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||
<span className="bg-white/20 px-2 py-0.5 rounded">{stats?.pending_withdrawals} pending</span>
|
||
</div>
|
||
)}
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Charts */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-semibold text-gray-800">User Registrations</h3>
|
||
<select
|
||
value={chartDays}
|
||
onChange={(e) => setChartDays(Number(e.target.value))}
|
||
className="text-sm border rounded px-2 py-1"
|
||
>
|
||
<option value={7}>7 days</option>
|
||
<option value={14}>14 days</option>
|
||
<option value={30}>30 days</option>
|
||
</select>
|
||
</div>
|
||
<SimpleBarChart data={userChart} color="bg-blue-500" height={120} />
|
||
{userChart && (
|
||
<div className="flex justify-between mt-2 text-xs text-gray-400 overflow-hidden">
|
||
<span>{userChart.labels[0]}</span>
|
||
<span>{userChart.labels[userChart.labels.length - 1]}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-semibold text-gray-800">Vehicle Requests</h3>
|
||
</div>
|
||
<SimpleBarChart data={requestChart} color="bg-purple-500" height={120} />
|
||
{requestChart && (
|
||
<div className="flex justify-between mt-2 text-xs text-gray-400 overflow-hidden">
|
||
<span>{requestChart.labels[0]}</span>
|
||
<span>{requestChart.labels[requestChart.labels.length - 1]}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Two Column Layout */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* Recent Activity */}
|
||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||
<h3 className="font-semibold text-gray-800 mb-4">Recent Activity</h3>
|
||
<div className="space-y-3">
|
||
{recentActivities.length === 0 ? (
|
||
<p className="text-gray-400 text-center py-4">No recent activity</p>
|
||
) : (
|
||
recentActivities.map((activity, index) => (
|
||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||
<span className="text-xl">{getActivityIcon(activity.icon)}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-800 truncate">{activity.title}</p>
|
||
<p className="text-xs text-gray-500 truncate">{activity.description}</p>
|
||
</div>
|
||
<span className="text-xs text-gray-400 whitespace-nowrap">{getTimeAgo(activity.time)}</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Top Dealers */}
|
||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-semibold text-gray-800">Top Dealers</h3>
|
||
<Link href="/admin/dealers" className="text-sm text-primary-600 hover:underline">View all</Link>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{topDealers.length === 0 ? (
|
||
<p className="text-gray-400 text-center py-4">No dealers yet</p>
|
||
) : (
|
||
topDealers.map((dealer, index) => (
|
||
<div key={dealer.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||
<div className="w-8 h-8 bg-gradient-to-r from-amber-400 to-amber-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||
{index + 1}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-800">{dealer.name}</p>
|
||
<p className="text-xs text-gray-500">{dealer.dealer_code}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-sm font-semibold text-gray-800">{dealer.total_sales} sales</p>
|
||
<p className="text-xs text-green-600">{formatCurrency(dealer.total_commission)}</p>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Actions */}
|
||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<Link
|
||
href="/admin/cars"
|
||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||
>
|
||
<span className="text-2xl">🚙</span>
|
||
<div>
|
||
<p className="font-medium text-gray-800">Car Listings</p>
|
||
<p className="text-xs text-gray-500">{formatNumber(stats?.total_cars || 0)} cars</p>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link
|
||
href="/admin/settings"
|
||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||
>
|
||
<span className="text-2xl">⚙️</span>
|
||
<div>
|
||
<p className="font-medium text-gray-800">Settings</p>
|
||
<p className="text-xs text-gray-500">System config</p>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link
|
||
href="/admin/notifications"
|
||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||
>
|
||
<span className="text-2xl">🔔</span>
|
||
<div>
|
||
<p className="font-medium text-gray-800">Notifications</p>
|
||
<p className="text-xs text-gray-500">Send alerts</p>
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|