Add complete admin portal implementation with orders management
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m25s
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:
parent
e7c4fbca70
commit
4b408986e5
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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">
|
||||
<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"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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 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>
|
||||
|
||||
{/* Placeholder */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-12 text-center"
|
||||
>
|
||||
<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
|
||||
</div>
|
||||
</motion.div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
296
components/admin/ExportButton.tsx
Normal file
296
components/admin/ExportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
components/admin/OrderDetailsModal.tsx
Normal file
359
components/admin/OrderDetailsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
components/admin/OrderFilters.tsx
Normal file
198
components/admin/OrderFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
components/admin/OrderStatsCards.tsx
Normal file
96
components/admin/OrderStatsCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
components/admin/OrdersTable.tsx
Normal file
297
components/admin/OrdersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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?: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user