Matt 4b408986e5
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m25s
Add complete admin portal implementation with orders management
- 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
2025-11-03 22:24:17 +01:00

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>
);
}