puffin-app/components/admin/OrdersTable.tsx
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

298 lines
12 KiB
TypeScript

'use client';
import { motion } from 'framer-motion';
import { ChevronUp, ChevronDown, Eye, Loader2 } from 'lucide-react';
import { OrderRecord } from '@/src/types';
interface OrdersTableProps {
orders: OrderRecord[];
isLoading?: boolean;
onViewDetails: (order: OrderRecord) => void;
totalCount: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onSort: (key: keyof OrderRecord) => void;
sortKey: keyof OrderRecord | null;
sortOrder: 'asc' | 'desc';
}
export function OrdersTable({
orders,
isLoading = false,
onViewDetails,
totalCount,
currentPage,
pageSize,
onPageChange,
onPageSizeChange,
onSort,
sortKey,
sortOrder,
}: OrdersTableProps) {
const totalPages = Math.ceil(totalCount / pageSize);
const getStatusBadgeClass = (status: string) => {
switch (status) {
case 'pending':
return 'bg-gradient-to-r from-muted-gold/20 to-orange-400/20 text-muted-gold border border-muted-gold/30';
case 'paid':
return 'bg-gradient-to-r from-maritime-teal/20 to-teal-400/20 text-maritime-teal border border-maritime-teal/30';
case 'fulfilled':
return 'bg-gradient-to-r from-sea-green/20 to-green-400/20 text-sea-green border border-sea-green/30';
case 'cancelled':
return 'bg-gradient-to-r from-red-500/20 to-red-400/20 text-red-600 border border-red-500/30';
default:
return 'bg-gray-100 text-gray-700 border border-gray-300';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatCurrency = (amount: string, currency: string = 'USD') => {
const numAmount = parseFloat(amount) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
}).format(numAmount);
};
const getSortIcon = (column: keyof OrderRecord) => {
if (sortKey !== column) {
return <ChevronUp className="w-4 h-4 text-gray-400" />;
}
return sortOrder === 'asc' ? (
<ChevronUp className="w-4 h-4 text-deep-sea-blue" />
) : (
<ChevronDown className="w-4 h-4 text-deep-sea-blue" />
);
};
if (isLoading) {
return (
<div className="bg-white border border-light-gray-border rounded-xl overflow-hidden">
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-deep-sea-blue animate-spin" />
</div>
</div>
);
}
if (orders.length === 0) {
return (
<div className="bg-white border border-light-gray-border rounded-xl overflow-hidden">
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<div className="text-6xl mb-4">📦</div>
<h3 className="text-xl font-bold text-deep-sea-blue mb-2">No Orders Found</h3>
<p className="text-deep-sea-blue/60">There are no orders matching your criteria.</p>
</div>
</div>
);
}
return (
<div className="bg-white border border-light-gray-border rounded-xl overflow-hidden">
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-light-gray-border">
<tr>
<th
onClick={() => onSort('orderId')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Order ID</span>
{getSortIcon('orderId')}
</div>
</th>
<th
onClick={() => onSort('customerName')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Customer</span>
{getSortIcon('customerName')}
</div>
</th>
<th
onClick={() => onSort('totalAmount')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Amount</span>
{getSortIcon('totalAmount')}
</div>
</th>
<th
onClick={() => onSort('co2Tons')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>CO Offset</span>
{getSortIcon('co2Tons')}
</div>
</th>
<th
onClick={() => onSort('status')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Status</span>
{getSortIcon('status')}
</div>
</th>
<th
onClick={() => onSort('CreatedAt')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Created</span>
{getSortIcon('CreatedAt')}
</div>
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-deep-sea-blue uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-gray-border">
{orders.map((order, index) => (
<motion.tr
key={order.Id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-mono text-deep-sea-blue">
{order.orderId ? `${order.orderId.substring(0, 8)}...` : 'N/A'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-deep-sea-blue">{order.customerName}</div>
<div className="text-xs text-deep-sea-blue/60">{order.customerEmail}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-deep-sea-blue">
{formatCurrency(order.totalAmount, order.currency)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-sea-green/20 text-sea-green border border-sea-green/30">
{parseFloat(order.co2Tons).toFixed(2)} tons
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeClass(
order.status
)}`}
>
{order.status ? order.status.charAt(0).toUpperCase() + order.status.slice(1) : 'Unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-deep-sea-blue/70">
{formatDate(order.CreatedAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => onViewDetails(order)}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-deep-sea-blue bg-deep-sea-blue/10 hover:bg-deep-sea-blue hover:text-white rounded-lg transition-colors"
>
<Eye className="w-4 h-4 mr-1" />
View
</button>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-light-gray-border bg-gray-50">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-deep-sea-blue/70">Rows per page:</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="px-3 py-1.5 text-sm border border-light-gray-border rounded-lg bg-white text-deep-sea-blue focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="text-sm text-deep-sea-blue/70">
Showing {(currentPage - 1) * pageSize + 1} to{' '}
{Math.min(currentPage * pageSize, totalCount)} of {totalCount} orders
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<div className="hidden sm:flex items-center space-x-1">
{Array.from({ length: Math.min(totalPages, 5) }).map((_, i) => {
let pageNumber;
if (totalPages <= 5) {
pageNumber = i + 1;
} else if (currentPage <= 3) {
pageNumber = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNumber = totalPages - 4 + i;
} else {
pageNumber = currentPage - 2 + i;
}
if (pageNumber < 1 || pageNumber > totalPages) return null;
return (
<button
key={pageNumber}
onClick={() => onPageChange(pageNumber)}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNumber
? 'bg-deep-sea-blue text-white'
: 'text-deep-sea-blue bg-white border border-light-gray-border hover:bg-gray-50'
}`}
>
{pageNumber}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
}