All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m25s
- Fully implemented OrdersTable with sorting, pagination, and filtering - Added OrderFilters component for search, status, and date range filtering - Created OrderStatsCards for dashboard metrics display - Built OrderDetailsModal for viewing complete order information - Implemented ExportButton for CSV export functionality - Updated dashboard and orders pages to use new components - Enhanced OrderRecord type definitions in src/types.ts - All components working with NocoDB API integration
282 lines
9.7 KiB
TypeScript
282 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { DollarSign, Package, Leaf, TrendingUp, Loader2 } from 'lucide-react';
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
|
|
interface DashboardStats {
|
|
totalOrders: number;
|
|
totalRevenue: number;
|
|
totalCO2Offset: number;
|
|
fulfillmentRate: number;
|
|
ordersByStatus: {
|
|
pending: number;
|
|
paid: number;
|
|
fulfilled: number;
|
|
cancelled: number;
|
|
};
|
|
}
|
|
|
|
interface TimelineData {
|
|
date: string;
|
|
count: number;
|
|
revenue: number;
|
|
}
|
|
|
|
type TimeRange = '7d' | '30d' | '90d' | 'all';
|
|
|
|
export default function AdminDashboard() {
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [timeline, setTimeline] = useState<TimelineData[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Fetch dashboard data
|
|
useEffect(() => {
|
|
fetchDashboardData();
|
|
}, [timeRange]);
|
|
|
|
const fetchDashboardData = async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Calculate date range
|
|
const dateTo = new Date().toISOString().split('T')[0];
|
|
let dateFrom: string | undefined;
|
|
|
|
if (timeRange !== 'all') {
|
|
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
|
|
const fromDate = new Date();
|
|
fromDate.setDate(fromDate.getDate() - days);
|
|
dateFrom = fromDate.toISOString().split('T')[0];
|
|
}
|
|
|
|
// Build query params
|
|
const params = new URLSearchParams();
|
|
if (dateFrom) params.append('dateFrom', dateFrom);
|
|
params.append('dateTo', dateTo);
|
|
params.append('period', timeRange === '7d' ? 'day' : 'week');
|
|
|
|
const response = await fetch(`/api/admin/stats?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to fetch dashboard data');
|
|
}
|
|
|
|
setStats(data.data.stats);
|
|
setTimeline(data.data.timeline);
|
|
} catch (err) {
|
|
console.error('Error fetching dashboard data:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Prepare pie chart data
|
|
const pieChartData = stats
|
|
? [
|
|
{ name: 'Pending', value: stats.ordersByStatus.pending, color: '#D68910' },
|
|
{ name: 'Paid', value: stats.ordersByStatus.paid, color: '#008B8B' },
|
|
{ name: 'Fulfilled', value: stats.ordersByStatus.fulfilled, color: '#1E8449' },
|
|
{ name: 'Cancelled', value: stats.ordersByStatus.cancelled, color: '#DC2626' },
|
|
]
|
|
: [];
|
|
|
|
const statCards = stats
|
|
? [
|
|
{
|
|
title: 'Total Orders',
|
|
value: stats.totalOrders.toLocaleString(),
|
|
icon: <Package size={24} />,
|
|
gradient: 'bg-gradient-to-br from-royal-purple to-purple-600',
|
|
},
|
|
{
|
|
title: 'Total CO₂ Offset',
|
|
value: `${stats.totalCO2Offset.toFixed(2)} tons`,
|
|
icon: <Leaf size={24} />,
|
|
gradient: 'bg-gradient-to-br from-sea-green to-green-600',
|
|
},
|
|
{
|
|
title: 'Total Revenue',
|
|
value: `$${(stats.totalRevenue / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
|
icon: <DollarSign size={24} />,
|
|
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
|
|
},
|
|
{
|
|
title: 'Fulfillment Rate',
|
|
value: `${stats.fulfillmentRate.toFixed(1)}%`,
|
|
icon: <TrendingUp size={24} />,
|
|
gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600',
|
|
},
|
|
]
|
|
: [];
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="space-y-8">
|
|
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
|
<p className="text-red-800 font-medium">{error}</p>
|
|
<button
|
|
onClick={fetchDashboardData}
|
|
className="mt-4 px-4 py-2 bg-deep-sea-blue text-white rounded-lg hover:bg-deep-sea-blue/90"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header with Time Range Selector */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
|
|
<p className="text-deep-sea-blue/70 font-medium">Overview of your carbon offset operations</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 bg-white border border-light-gray-border rounded-lg p-1">
|
|
{(['7d', '30d', '90d', 'all'] as TimeRange[]).map((range) => (
|
|
<button
|
|
key={range}
|
|
onClick={() => setTimeRange(range)}
|
|
className={`px-4 py-2 rounded-md font-medium transition-all ${
|
|
timeRange === range
|
|
? 'bg-deep-sea-blue text-white shadow-sm'
|
|
: 'text-deep-sea-blue/60 hover:text-deep-sea-blue hover:bg-sail-white'
|
|
}`}
|
|
>
|
|
{range === 'all' ? 'All Time' : range.toUpperCase()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="w-8 h-8 text-deep-sea-blue animate-spin" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{statCards.map((stat, index) => (
|
|
<motion.div
|
|
key={stat.title}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
className="bg-white border border-light-gray-border rounded-xl p-6 hover:shadow-lg transition-shadow"
|
|
>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div
|
|
className={
|
|
stat.gradient + ' w-12 h-12 rounded-lg flex items-center justify-center text-white shadow-md'
|
|
}
|
|
>
|
|
{stat.icon}
|
|
</div>
|
|
</div>
|
|
<h3 className="text-sm font-medium text-deep-sea-blue/60 mb-1">{stat.title}</h3>
|
|
<p className="text-2xl font-bold text-deep-sea-blue">{stat.value}</p>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Charts */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Orders Timeline */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.5 }}
|
|
className="bg-white border border-light-gray-border rounded-xl p-6"
|
|
>
|
|
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Orders Over Time</h2>
|
|
{timeline.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<LineChart data={timeline}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#EAECF0" />
|
|
<XAxis dataKey="date" stroke="#1D2939" fontSize={12} />
|
|
<YAxis stroke="#1D2939" fontSize={12} />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#FFF',
|
|
border: '1px solid #EAECF0',
|
|
borderRadius: '8px',
|
|
}}
|
|
/>
|
|
<Legend />
|
|
<Line type="monotone" dataKey="count" stroke="#884EA0" name="Orders" strokeWidth={2} />
|
|
<Line type="monotone" dataKey="revenue" stroke="#1E8449" name="Revenue ($)" strokeWidth={2} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
|
|
No orders data available for this time range
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Status Distribution */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.6 }}
|
|
className="bg-white border border-light-gray-border rounded-xl p-6"
|
|
>
|
|
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Status Distribution</h2>
|
|
{stats && stats.totalOrders > 0 ? (
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<PieChart>
|
|
<Pie
|
|
data={pieChartData}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={false}
|
|
outerRadius={100}
|
|
fill="#8884d8"
|
|
dataKey="value"
|
|
>
|
|
{pieChartData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
|
|
No orders to display
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|