Add complete admin portal implementation with orders management
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
This commit is contained in:
Matt 2025-11-03 22:24:17 +01:00
parent e7c4fbca70
commit 4b408986e5
9 changed files with 1548 additions and 81 deletions

View File

@ -72,6 +72,7 @@ export class NocoDBClient {
/**
* Build NocoDB where clause from filters
* Note: Date filtering is done client-side since NocoDB columns are strings
*/
private buildWhereClause(filters: OrderFilters): string {
const conditions: string[] = [];
@ -88,13 +89,8 @@ export class NocoDBClient {
conditions.push(`(imoNumber,eq,${filters.imoNumber})`);
}
if (filters.dateFrom) {
conditions.push(`(CreatedAt,gte,${filters.dateFrom})`);
}
if (filters.dateTo) {
conditions.push(`(CreatedAt,lte,${filters.dateTo})`);
}
// Date filtering removed from WHERE clause - done client-side instead
// NocoDB rejects date comparisons when columns are stored as strings
if (filters.minAmount !== undefined) {
conditions.push(`(totalAmount,gte,${filters.minAmount})`);
@ -107,6 +103,35 @@ export class NocoDBClient {
return conditions.length > 0 ? conditions.join('~and') : '';
}
/**
* Filter orders by date range (client-side)
*/
private filterOrdersByDate(orders: any[], dateFrom?: string, dateTo?: string): any[] {
// Check for empty strings or undefined/null
const hasDateFrom = dateFrom && dateFrom.trim() !== '';
const hasDateTo = dateTo && dateTo.trim() !== '';
if (!hasDateFrom && !hasDateTo) return orders;
return orders.filter(order => {
const orderDate = new Date(order.CreatedAt);
if (hasDateFrom) {
const fromDate = new Date(dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (orderDate < fromDate) return false;
}
if (hasDateTo) {
const toDate = new Date(dateTo);
toDate.setHours(23, 59, 59, 999);
if (orderDate > toDate) return false;
}
return true;
});
}
/**
* Build sort parameter
*/
@ -148,7 +173,7 @@ export class NocoDBClient {
): Promise<NocoDBResponse<any>> {
const params = new URLSearchParams();
// Add where clause if filters exist
// Add where clause if filters exist (excludes date filters)
const whereClause = this.buildWhereClause(filters);
if (whereClause) {
params.append('where', whereClause);
@ -158,18 +183,40 @@ export class NocoDBClient {
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
// Add pagination
if (pagination.limit) {
params.append('limit', pagination.limit.toString());
}
if (pagination.offset) {
params.append('offset', pagination.offset.toString());
}
// Fetch all records for client-side date filtering (up to 10000)
params.append('limit', '10000');
const queryString = params.toString();
const endpoint = queryString ? `?${queryString}` : '';
return this.request<NocoDBResponse<any>>(endpoint);
const response = await this.request<NocoDBResponse<any>>(endpoint);
// Apply client-side date filtering
const filteredOrders = this.filterOrdersByDate(
response.list,
filters.dateFrom,
filters.dateTo
);
// Apply pagination to filtered results
const requestedLimit = pagination.limit || 25;
const requestedOffset = pagination.offset || 0;
const paginatedOrders = filteredOrders.slice(
requestedOffset,
requestedOffset + requestedLimit
);
// Update response with filtered and paginated results
return {
list: paginatedOrders,
pageInfo: {
totalRows: filteredOrders.length,
page: Math.floor(requestedOffset / requestedLimit) + 1,
pageSize: requestedLimit,
isFirstPage: requestedOffset === 0,
isLastPage: requestedOffset + requestedLimit >= filteredOrders.length,
},
};
}
/**
@ -213,18 +260,16 @@ export class NocoDBClient {
* Get order statistics for dashboard
*/
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
const filters: OrderFilters = {};
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
// Fetch all orders without date filtering
const response = await this.getOrders({}, { limit: 10000 });
// Get all orders with filters (no pagination for stats calculation)
const response = await this.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Apply client-side date filtering
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
// Calculate stats
// Calculate stats (parse string amounts from NocoDB)
const totalOrders = orders.length;
const totalRevenue = orders.reduce((sum, order) => sum + (order.totalAmount || 0), 0);
const totalCO2Offset = orders.reduce((sum, order) => sum + (order.co2Tons || 0), 0);
const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount || '0'), 0);
const totalCO2Offset = orders.reduce((sum, order) => sum + parseFloat(order.co2Tons || '0'), 0);
const ordersByStatus = {
pending: orders.filter((o) => o.status === 'pending').length,
@ -273,12 +318,11 @@ export class NocoDBClient {
dateFrom?: string,
dateTo?: string
): Promise<Array<{ date: string; count: number; revenue: number }>> {
const filters: OrderFilters = {};
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
// Fetch all orders without date filtering
const response = await this.getOrders({}, { limit: 10000 });
const response = await this.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Apply client-side date filtering
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
// Group orders by time period
const grouped = new Map<string, { count: number; revenue: number }>();
@ -304,7 +348,7 @@ export class NocoDBClient {
const existing = grouped.get(key) || { count: 0, revenue: 0 };
grouped.set(key, {
count: existing.count + 1,
revenue: existing.revenue + (order.totalAmount || 0),
revenue: existing.revenue + parseFloat(order.totalAmount || '0'),
});
});
@ -317,13 +361,9 @@ export class NocoDBClient {
* Get count of records matching filters
*/
async getCount(filters: OrderFilters = {}): Promise<number> {
const whereClause = this.buildWhereClause(filters);
const params = whereClause ? `?where=${whereClause}` : '';
const countUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records/count${params}`;
const response = await this.request<{ count: number }>(countUrl);
return response.count;
// Fetch all orders and count after client-side filtering
const response = await this.getOrders(filters, { limit: 10000 });
return response.list.length;
}
}

View File

@ -115,7 +115,7 @@ export default function AdminDashboard() {
},
{
title: 'Total Revenue',
value: `$${stats.totalRevenue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
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',
},
@ -254,26 +254,8 @@ export default function AdminDashboard() {
cx="50%"
cy="50%"
labelLine={false}
label={(props: any) => {
const RADIAN = Math.PI / 180;
const { cx, cy, midAngle, innerRadius, outerRadius, percent, name } = props;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
>
{`${name}: ${(percent * 100).toFixed(0)}%`}
</text>
);
}}
outerRadius={80}
label={false}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>

View File

@ -1,32 +1,231 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Package } from 'lucide-react';
import { OrderStatsCards } from '@/components/admin/OrderStatsCards';
import { OrdersTable } from '@/components/admin/OrdersTable';
import { OrderFilters } from '@/components/admin/OrderFilters';
import { OrderDetailsModal } from '@/components/admin/OrderDetailsModal';
import { ExportButton } from '@/components/admin/ExportButton';
import { OrderRecord } from '@/src/types';
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
}
export default function AdminOrders() {
const [orders, setOrders] = useState<OrderRecord[]>([]);
const [stats, setStats] = useState<OrderStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [sortKey, setSortKey] = useState<keyof OrderRecord | null>('CreatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
// Fetch orders and stats
useEffect(() => {
fetchData();
}, [currentPage, pageSize, sortKey, sortOrder, searchTerm, statusFilter, dateFrom, dateTo]);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
// Build query params for orders
const params = new URLSearchParams();
params.append('limit', pageSize.toString());
params.append('offset', ((currentPage - 1) * pageSize).toString());
if (sortKey) {
params.append('sortBy', sortKey);
params.append('sortOrder', sortOrder);
}
// Add filters
if (searchTerm) {
params.append('search', searchTerm);
}
if (statusFilter) {
params.append('status', statusFilter);
}
if (dateFrom) {
params.append('dateFrom', dateFrom);
}
if (dateTo) {
params.append('dateTo', dateTo);
}
// Fetch orders
const ordersResponse = await fetch(`/api/admin/orders?${params}`);
const ordersData = await ordersResponse.json();
if (!ordersResponse.ok) {
throw new Error(ordersData.error || 'Failed to fetch orders');
}
setOrders(ordersData.data || []);
setTotalCount(ordersData.pagination?.totalRows || 0);
// Fetch stats (for current month)
const statsResponse = await fetch('/api/admin/stats');
const statsData = await statsResponse.json();
if (statsResponse.ok) {
setStats(statsData.data.stats);
}
} catch (err) {
console.error('Error fetching data:', err);
setError(err instanceof Error ? err.message : 'Failed to load orders');
} finally {
setIsLoading(false);
}
};
const handleSort = (key: keyof OrderRecord) => {
if (sortKey === key) {
// Toggle sort order if same column
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
// New column, default to ascending
setSortKey(key);
setSortOrder('asc');
}
};
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setCurrentPage(1); // Reset to first page when changing page size
};
const handleViewDetails = (order: OrderRecord) => {
setSelectedOrder(order);
setIsDetailsModalOpen(true);
};
const handleCloseDetailsModal = () => {
setIsDetailsModalOpen(false);
// Optional: clear selected order after animation completes
setTimeout(() => setSelectedOrder(null), 300);
};
const handleSearchChange = (search: string) => {
setSearchTerm(search);
setCurrentPage(1); // Reset to first page on filter change
};
const handleStatusChange = (status: string) => {
setStatusFilter(status);
setCurrentPage(1);
};
const handleDateRangeChange = (from: string, to: string) => {
setDateFrom(from);
setDateTo(to);
setCurrentPage(1);
};
const handleResetFilters = () => {
setSearchTerm('');
setStatusFilter('');
setDateFrom('');
setDateTo('');
setCurrentPage(1);
};
if (error) {
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Orders</h1>
<p className="text-gray-600">View and manage all carbon offset orders</p>
</div>
{/* Placeholder */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card p-12 text-center"
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Orders</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={fetchData}
className="mt-4 px-4 py-2 bg-deep-sea-blue text-white rounded-lg hover:bg-deep-sea-blue/90 transition-colors"
>
<Package size={48} className="mx-auto mb-4 text-gray-400" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Orders Management</h2>
<p className="text-gray-600 mb-4">
Orders table with filtering, search, and export will be implemented in Phase 4.
</p>
<div className="inline-block px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm">
Backend API integration coming in Phase 2
Retry
</button>
</div>
</motion.div>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-8"
>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Orders</h1>
<p className="text-deep-sea-blue/70 font-medium">
View and manage all carbon offset orders
</p>
</div>
<ExportButton
currentOrders={orders}
filters={{
search: searchTerm,
status: statusFilter,
dateFrom: dateFrom,
dateTo: dateTo,
}}
/>
</div>
{/* Stats Cards */}
<OrderStatsCards stats={stats} isLoading={isLoading && !stats} />
{/* Filters */}
<OrderFilters
onSearchChange={handleSearchChange}
onStatusChange={handleStatusChange}
onDateRangeChange={handleDateRangeChange}
onReset={handleResetFilters}
searchValue={searchTerm}
statusValue={statusFilter}
dateFromValue={dateFrom}
dateToValue={dateTo}
/>
{/* Orders Table */}
<OrdersTable
orders={orders}
isLoading={isLoading}
onViewDetails={handleViewDetails}
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSort={handleSort}
sortKey={sortKey}
sortOrder={sortOrder}
/>
{/* Order Details Modal */}
<OrderDetailsModal
order={selectedOrder}
isOpen={isDetailsModalOpen}
onClose={handleCloseDetailsModal}
/>
</motion.div>
);
}

View File

@ -0,0 +1,296 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Download, Loader2 } from 'lucide-react';
import { OrderRecord } from '@/src/types';
interface ExportButtonProps {
currentOrders?: OrderRecord[];
filters?: {
search?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
};
}
export function ExportButton({ currentOrders = [], filters = {} }: ExportButtonProps) {
const [isExporting, setIsExporting] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
// Close dropdown on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowDropdown(false);
};
if (showDropdown) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [showDropdown]);
const formatDate = (dateString: string | undefined) => {
if (!dateString) return '';
return new Date(dateString).toISOString();
};
const formatCurrency = (amount: string | undefined) => {
if (!amount) return '';
return (parseFloat(amount) / 100).toFixed(2);
};
const convertToCSV = (orders: OrderRecord[]): string => {
if (orders.length === 0) return '';
// CSV Headers
const headers = [
'Order ID',
'Status',
'Source',
'Created At',
'Updated At',
'Customer Name',
'Customer Email',
'Customer Phone',
'Business Name',
'Tax ID Type',
'Tax ID Value',
'Base Amount',
'Processing Fee',
'Total Amount',
'Currency',
'Payment Method',
'Stripe Session ID',
'Stripe Payment Intent',
'Stripe Customer ID',
'CO2 Tons',
'Portfolio ID',
'Portfolio Name',
'Wren Order ID',
'Certificate URL',
'Fulfilled At',
'Billing Line 1',
'Billing Line 2',
'Billing City',
'Billing State',
'Billing Postal Code',
'Billing Country',
'Vessel Name',
'IMO Number',
'Vessel Type',
'Vessel Length',
'Departure Port',
'Arrival Port',
'Distance',
'Avg Speed',
'Duration',
'Engine Power',
'Notes',
];
// CSV Rows
const rows = orders.map((order) => [
order.orderId,
order.status,
order.source || '',
formatDate(order.CreatedAt),
formatDate(order.UpdatedAt),
order.customerName,
order.customerEmail,
order.customerPhone || '',
order.businessName || '',
order.taxIdType || '',
order.taxIdValue || '',
formatCurrency(order.baseAmount),
formatCurrency(order.processingFee),
formatCurrency(order.totalAmount),
order.currency,
order.paymentMethod || '',
order.stripeSessionId || '',
order.stripePaymentIntent || '',
order.stripeCustomerId || '',
parseFloat(order.co2Tons).toFixed(2),
order.portfolioId,
order.portfolioName || '',
order.wrenOrderId || '',
order.certificateUrl || '',
formatDate(order.fulfilledAt),
order.billingLine1 || '',
order.billingLine2 || '',
order.billingCity || '',
order.billingState || '',
order.billingPostalCode || '',
order.billingCountry || '',
order.vesselName || '',
order.imoNumber || '',
order.vesselType || '',
order.vesselLength || '',
order.departurePort || '',
order.arrivalPort || '',
order.distance || '',
order.avgSpeed || '',
order.duration || '',
order.enginePower || '',
order.notes || '',
]);
// Escape CSV fields (handle commas, quotes, newlines)
const escapeCSVField = (field: string | number) => {
const stringField = String(field);
if (
stringField.includes(',') ||
stringField.includes('"') ||
stringField.includes('\n')
) {
return `"${stringField.replace(/"/g, '""')}"`;
}
return stringField;
};
// Build CSV
const csvContent = [
headers.map(escapeCSVField).join(','),
...rows.map((row) => row.map(escapeCSVField).join(',')),
].join('\n');
return csvContent;
};
const downloadCSV = (csvContent: string, filename: string) => {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleExportCurrent = () => {
setIsExporting(true);
setShowDropdown(false);
try {
const csvContent = convertToCSV(currentOrders);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `orders-current-${timestamp}.csv`;
downloadCSV(csvContent, filename);
} catch (error) {
console.error('Export failed:', error);
alert('Failed to export orders. Please try again.');
} finally {
setTimeout(() => setIsExporting(false), 1000);
}
};
const handleExportAll = async () => {
setIsExporting(true);
setShowDropdown(false);
try {
// Build query params with current filters
const params = new URLSearchParams();
params.append('limit', '10000'); // High limit to get all
params.append('offset', '0');
if (filters.search) params.append('search', filters.search);
if (filters.status) params.append('status', filters.status);
if (filters.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters.dateTo) params.append('dateTo', filters.dateTo);
// Fetch all orders
const response = await fetch(`/api/admin/orders?${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch orders');
}
const allOrders = data.data.list || [];
const csvContent = convertToCSV(allOrders);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `orders-all-${timestamp}.csv`;
downloadCSV(csvContent, filename);
} catch (error) {
console.error('Export failed:', error);
alert('Failed to export all orders. Please try again.');
} finally {
setTimeout(() => setIsExporting(false), 1000);
}
};
return (
<div className="relative inline-block">
<button
type="button"
onClick={() => setShowDropdown(!showDropdown)}
disabled={isExporting}
className="flex items-center space-x-2 px-4 py-2.5 text-sm font-medium text-white bg-deep-sea-blue hover:bg-deep-sea-blue/90 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
title="Export orders to CSV"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Exporting...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span>Export</span>
</>
)}
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{showDropdown && (
<>
{/* Backdrop to close dropdown */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-10"
onClick={() => setShowDropdown(false)}
/>
{/* Dropdown */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 mt-2 w-64 bg-white border border-light-gray-border rounded-xl shadow-lg z-20 overflow-hidden"
>
<button
type="button"
onClick={handleExportCurrent}
className="w-full px-4 py-3 text-left text-sm text-deep-sea-blue hover:bg-gray-50 transition-colors border-b border-gray-100"
>
<div className="font-medium">Export Current Page</div>
<div className="text-xs text-deep-sea-blue/60 mt-0.5">
{currentOrders.length} orders
</div>
</button>
<button
type="button"
onClick={handleExportAll}
className="w-full px-4 py-3 text-left text-sm text-deep-sea-blue hover:bg-gray-50 transition-colors"
>
<div className="font-medium">Export All (with filters)</div>
<div className="text-xs text-deep-sea-blue/60 mt-0.5">
All matching orders as CSV
</div>
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,359 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Copy, Check, ExternalLink } from 'lucide-react';
import { useState } from 'react';
import { OrderRecord } from '@/src/types';
interface OrderDetailsModalProps {
order: OrderRecord | null;
isOpen: boolean;
onClose: () => void;
}
export function OrderDetailsModal({ order, isOpen, onClose }: OrderDetailsModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
if (!order) return null;
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const formatDate = (dateString: string | undefined) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatCurrency = (amount: string | undefined, currency: string = 'USD') => {
if (!amount) return 'N/A';
const numAmount = parseFloat(amount) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(numAmount);
};
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 CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
<button
type="button"
onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 p-1 hover:bg-gray-100 rounded transition-colors"
title="Copy to clipboard"
>
{copiedField === fieldName ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-deep-sea-blue/40" />
)}
</button>
);
const DetailField = ({
label,
value,
copyable = false,
fieldName = '',
}: {
label: string;
value: string | number | undefined | null;
copyable?: boolean;
fieldName?: string;
}) => {
const displayValue = value || 'N/A';
return (
<div className="py-3 border-b border-gray-100 last:border-0">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">{label}</div>
<div className="flex items-center">
<div className="text-sm text-deep-sea-blue font-medium">{displayValue}</div>
{copyable && value && <CopyButton text={String(value)} fieldName={fieldName} />}
</div>
</div>
);
};
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-lg font-bold text-deep-sea-blue mb-4 flex items-center">
<div className="w-1 h-6 bg-deep-sea-blue rounded-full mr-3"></div>
{title}
</h3>
);
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 z-40"
/>
{/* Sliding Panel */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed right-0 top-0 bottom-0 w-full max-w-2xl bg-white shadow-2xl z-50 overflow-hidden flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 bg-gray-50">
<div>
<h2 className="text-2xl font-bold text-deep-sea-blue">Order Details</h2>
<p className="text-sm text-deep-sea-blue/60 mt-1">
ID: {order.orderId.substring(0, 12)}...
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="w-6 h-6 text-deep-sea-blue" />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Order Information */}
<section>
<SectionHeader title="Order Information" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Order ID" value={order.orderId} copyable fieldName="orderId" />
<div className="py-3 border-b border-gray-100">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">Status</div>
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeClass(
order.status
)}`}
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<DetailField label="Source" value={order.source || 'web'} />
<DetailField label="Created At" value={formatDate(order.CreatedAt)} />
<DetailField label="Last Updated" value={formatDate(order.UpdatedAt)} />
</div>
</section>
{/* Payment Details */}
<section>
<SectionHeader title="Payment Details" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField
label="Total Amount"
value={formatCurrency(order.totalAmount, order.currency)}
/>
<DetailField
label="Base Amount"
value={formatCurrency(order.baseAmount, order.currency)}
/>
<DetailField
label="Processing Fee"
value={formatCurrency(order.processingFee, order.currency)}
/>
<DetailField label="Currency" value={order.currency} />
{order.amountUSD && (
<DetailField label="Amount (USD)" value={formatCurrency(order.amountUSD, 'USD')} />
)}
<DetailField label="Payment Method" value={order.paymentMethod} />
<DetailField
label="Stripe Session ID"
value={order.stripeSessionId}
copyable
fieldName="stripeSessionId"
/>
<DetailField
label="Stripe Payment Intent"
value={order.stripePaymentIntent}
copyable
fieldName="stripePaymentIntent"
/>
<DetailField
label="Stripe Customer ID"
value={order.stripeCustomerId}
copyable
fieldName="stripeCustomerId"
/>
</div>
</section>
{/* Customer Information */}
<section>
<SectionHeader title="Customer Information" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Name" value={order.customerName} />
<DetailField label="Email" value={order.customerEmail} copyable fieldName="email" />
<DetailField label="Phone" value={order.customerPhone} />
{order.businessName && (
<>
<DetailField label="Business Name" value={order.businessName} />
<DetailField label="Tax ID Type" value={order.taxIdType} />
<DetailField
label="Tax ID Value"
value={order.taxIdValue}
copyable
fieldName="taxId"
/>
</>
)}
</div>
</section>
{/* Billing Address */}
<section>
<SectionHeader title="Billing Address" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Address Line 1" value={order.billingLine1} />
<DetailField label="Address Line 2" value={order.billingLine2} />
<DetailField label="City" value={order.billingCity} />
<DetailField label="State/Province" value={order.billingState} />
<DetailField label="Postal Code" value={order.billingPostalCode} />
<DetailField label="Country" value={order.billingCountry} />
</div>
</section>
{/* Carbon Offset Details */}
<section>
<SectionHeader title="Carbon Offset Details" />
<div className="bg-gray-50 rounded-xl p-4">
<div className="py-3 border-b border-gray-100">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">CO Offset</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-sea-green/20 text-sea-green border border-sea-green/30">
{parseFloat(order.co2Tons).toFixed(2)} tons
</span>
</div>
<DetailField label="Portfolio ID" value={order.portfolioId} />
<DetailField label="Portfolio Name" value={order.portfolioName} />
<DetailField
label="Wren Order ID"
value={order.wrenOrderId}
copyable
fieldName="wrenOrderId"
/>
{order.certificateUrl && (
<div className="py-3">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">
Certificate URL
</div>
<a
href={order.certificateUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-maritime-teal hover:text-maritime-teal/80 flex items-center font-medium"
>
View Certificate
<ExternalLink className="w-4 h-4 ml-1" />
</a>
</div>
)}
<DetailField label="Fulfilled At" value={formatDate(order.fulfilledAt)} />
</div>
</section>
{/* Vessel Information (if applicable) */}
{(order.vesselName || order.imoNumber || order.vesselType || order.vesselLength) && (
<section>
<SectionHeader title="Vessel Information" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Vessel Name" value={order.vesselName} />
<DetailField label="IMO Number" value={order.imoNumber} />
<DetailField label="Vessel Type" value={order.vesselType} />
<DetailField
label="Vessel Length"
value={order.vesselLength ? `${order.vesselLength}m` : undefined}
/>
</div>
</section>
)}
{/* Trip Details (if applicable) */}
{(order.departurePort ||
order.arrivalPort ||
order.distance ||
order.avgSpeed ||
order.duration ||
order.enginePower) && (
<section>
<SectionHeader title="Trip Details" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Departure Port" value={order.departurePort} />
<DetailField label="Arrival Port" value={order.arrivalPort} />
<DetailField
label="Distance"
value={order.distance ? `${order.distance} nm` : undefined}
/>
<DetailField
label="Average Speed"
value={order.avgSpeed ? `${order.avgSpeed} knots` : undefined}
/>
<DetailField
label="Duration"
value={order.duration ? `${order.duration} hours` : undefined}
/>
<DetailField
label="Engine Power"
value={order.enginePower ? `${order.enginePower} HP` : undefined}
/>
</div>
</section>
)}
{/* Admin Notes */}
{order.notes && (
<section>
<SectionHeader title="Admin Notes" />
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-sm text-deep-sea-blue whitespace-pre-wrap">{order.notes}</p>
</div>
</section>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 transition-colors"
>
Close
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,198 @@
'use client';
import { useState } from 'react';
import { Search, Filter, X, Calendar } from 'lucide-react';
interface OrderFiltersProps {
onSearchChange: (search: string) => void;
onStatusChange: (status: string) => void;
onDateRangeChange: (dateFrom: string, dateTo: string) => void;
onReset: () => void;
searchValue: string;
statusValue: string;
dateFromValue: string;
dateToValue: string;
}
export function OrderFilters({
onSearchChange,
onStatusChange,
onDateRangeChange,
searchValue,
statusValue,
dateFromValue,
dateToValue,
onReset,
}: OrderFiltersProps) {
const [showDatePicker, setShowDatePicker] = useState(false);
const [localDateFrom, setLocalDateFrom] = useState(dateFromValue);
const [localDateTo, setLocalDateTo] = useState(dateToValue);
const handleApplyDateRange = () => {
onDateRangeChange(localDateFrom, localDateTo);
setShowDatePicker(false);
};
const handleResetDateRange = () => {
setLocalDateFrom('');
setLocalDateTo('');
onDateRangeChange('', '');
setShowDatePicker(false);
};
const hasActiveFilters = searchValue || statusValue || dateFromValue || dateToValue;
return (
<div className="bg-white border border-light-gray-border rounded-xl p-6 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search Input */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-deep-sea-blue/40" />
<input
type="text"
placeholder="Search by order ID, customer name, or email..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-11 pr-4 py-2.5 border border-light-gray-border rounded-lg bg-white text-deep-sea-blue placeholder-deep-sea-blue/40 focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20 focus:border-deep-sea-blue transition-all"
/>
</div>
{/* Status Filter */}
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-deep-sea-blue/40" />
<select
value={statusValue}
onChange={(e) => onStatusChange(e.target.value)}
className="pl-10 pr-10 py-2.5 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 focus:border-deep-sea-blue transition-all cursor-pointer appearance-none"
style={{ minWidth: '160px' }}
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="fulfilled">Fulfilled</option>
<option value="cancelled">Cancelled</option>
</select>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none">
<svg className="w-4 h-4 text-deep-sea-blue/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Date Range Picker */}
<div className="relative">
<button
type="button"
onClick={() => setShowDatePicker(!showDatePicker)}
className="flex items-center space-x-2 px-4 py-2.5 border border-light-gray-border rounded-lg bg-white text-deep-sea-blue hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20 transition-all"
>
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">
{dateFromValue && dateToValue ? 'Date Range' : 'All Dates'}
</span>
{(dateFromValue || dateToValue) && (
<span className="px-2 py-0.5 text-xs bg-deep-sea-blue text-white rounded-full">1</span>
)}
</button>
{/* Date Picker Dropdown */}
{showDatePicker && (
<div className="absolute right-0 mt-2 p-4 bg-white border border-light-gray-border rounded-xl shadow-lg z-10 min-w-[300px]">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-deep-sea-blue mb-2">From Date</label>
<input
type="date"
value={localDateFrom}
onChange={(e) => setLocalDateFrom(e.target.value)}
className="w-full px-3 py-2 border border-light-gray-border rounded-lg focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-deep-sea-blue mb-2">To Date</label>
<input
type="date"
value={localDateTo}
onChange={(e) => setLocalDateTo(e.target.value)}
className="w-full px-3 py-2 border border-light-gray-border rounded-lg focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20"
/>
</div>
<div className="flex space-x-2 pt-2">
<button
type="button"
onClick={handleResetDateRange}
className="flex-1 px-4 py-2 text-sm font-medium text-deep-sea-blue bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Clear
</button>
<button
type="button"
onClick={handleApplyDateRange}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-deep-sea-blue hover:bg-deep-sea-blue/90 rounded-lg transition-colors"
>
Apply
</button>
</div>
</div>
</div>
)}
</div>
{/* Reset Filters Button */}
{hasActiveFilters && (
<button
type="button"
onClick={onReset}
className="flex items-center space-x-2 px-4 py-2.5 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
<span>Reset</span>
</button>
)}
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-light-gray-border">
<span className="text-sm font-medium text-deep-sea-blue/70">Active filters:</span>
{searchValue && (
<span className="inline-flex items-center px-3 py-1 text-xs font-medium bg-deep-sea-blue/10 text-deep-sea-blue rounded-full">
Search: "{searchValue}"
<button
type="button"
onClick={() => onSearchChange('')}
className="ml-2 hover:text-deep-sea-blue/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{statusValue && (
<span className="inline-flex items-center px-3 py-1 text-xs font-medium bg-deep-sea-blue/10 text-deep-sea-blue rounded-full">
Status: {statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
<button
type="button"
onClick={() => onStatusChange('')}
className="ml-2 hover:text-deep-sea-blue/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{(dateFromValue || dateToValue) && (
<span className="inline-flex items-center px-3 py-1 text-xs font-medium bg-deep-sea-blue/10 text-deep-sea-blue rounded-full">
Date: {dateFromValue || '...'} to {dateToValue || '...'}
<button
type="button"
onClick={() => onDateRangeChange('', '')}
className="ml-2 hover:text-deep-sea-blue/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,96 @@
'use client';
import { motion } from 'framer-motion';
import { Package, DollarSign, Leaf, TrendingUp } from 'lucide-react';
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
}
interface OrderStatsCardsProps {
stats: OrderStats | null;
isLoading?: boolean;
}
export function OrderStatsCards({ stats, isLoading = false }: OrderStatsCardsProps) {
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 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: 'Total CO₂ Offset',
value: `${(typeof stats.totalCO2Offset === 'number' ? stats.totalCO2Offset : parseFloat(stats.totalCO2Offset || '0')).toFixed(2)} tons`,
icon: <Leaf size={24} />,
gradient: 'bg-gradient-to-br from-sea-green to-green-600',
},
{
title: 'Fulfillment Rate',
value: `${(typeof stats.fulfillmentRate === 'number' ? stats.fulfillmentRate : parseFloat(stats.fulfillmentRate || '0')).toFixed(1)}%`,
icon: <TrendingUp size={24} />,
gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600',
},
]
: [];
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, index) => (
<div
key={index}
className="bg-white border border-light-gray-border rounded-xl p-6 animate-pulse"
>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 rounded-lg bg-gray-200"></div>
</div>
<div className="h-4 bg-gray-200 rounded w-24 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-32"></div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{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>
);
}

View File

@ -0,0 +1,297 @@
'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>
);
}

View File

@ -221,7 +221,7 @@ export interface QRCalculatorData {
fuelUnit?: 'liters' | 'gallons';
// Custom amount
customAmount?: number; // tons of CO2
customAmount?: number; // USD dollar amount for direct offsetting
// Vessel information (optional, for context)
vessel?: {