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
|
* Build NocoDB where clause from filters
|
||||||
|
* Note: Date filtering is done client-side since NocoDB columns are strings
|
||||||
*/
|
*/
|
||||||
private buildWhereClause(filters: OrderFilters): string {
|
private buildWhereClause(filters: OrderFilters): string {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
@ -88,13 +89,8 @@ export class NocoDBClient {
|
|||||||
conditions.push(`(imoNumber,eq,${filters.imoNumber})`);
|
conditions.push(`(imoNumber,eq,${filters.imoNumber})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.dateFrom) {
|
// Date filtering removed from WHERE clause - done client-side instead
|
||||||
conditions.push(`(CreatedAt,gte,${filters.dateFrom})`);
|
// NocoDB rejects date comparisons when columns are stored as strings
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.dateTo) {
|
|
||||||
conditions.push(`(CreatedAt,lte,${filters.dateTo})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.minAmount !== undefined) {
|
if (filters.minAmount !== undefined) {
|
||||||
conditions.push(`(totalAmount,gte,${filters.minAmount})`);
|
conditions.push(`(totalAmount,gte,${filters.minAmount})`);
|
||||||
@ -107,6 +103,35 @@ export class NocoDBClient {
|
|||||||
return conditions.length > 0 ? conditions.join('~and') : '';
|
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
|
* Build sort parameter
|
||||||
*/
|
*/
|
||||||
@ -148,7 +173,7 @@ export class NocoDBClient {
|
|||||||
): Promise<NocoDBResponse<any>> {
|
): Promise<NocoDBResponse<any>> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
// Add where clause if filters exist
|
// Add where clause if filters exist (excludes date filters)
|
||||||
const whereClause = this.buildWhereClause(filters);
|
const whereClause = this.buildWhereClause(filters);
|
||||||
if (whereClause) {
|
if (whereClause) {
|
||||||
params.append('where', whereClause);
|
params.append('where', whereClause);
|
||||||
@ -158,18 +183,40 @@ export class NocoDBClient {
|
|||||||
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
|
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
|
||||||
params.append('sort', sort);
|
params.append('sort', sort);
|
||||||
|
|
||||||
// Add pagination
|
// Fetch all records for client-side date filtering (up to 10000)
|
||||||
if (pagination.limit) {
|
params.append('limit', '10000');
|
||||||
params.append('limit', pagination.limit.toString());
|
|
||||||
}
|
|
||||||
if (pagination.offset) {
|
|
||||||
params.append('offset', pagination.offset.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const endpoint = queryString ? `?${queryString}` : '';
|
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
|
* Get order statistics for dashboard
|
||||||
*/
|
*/
|
||||||
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
|
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
|
||||||
const filters: OrderFilters = {};
|
// Fetch all orders without date filtering
|
||||||
if (dateFrom) filters.dateFrom = dateFrom;
|
const response = await this.getOrders({}, { limit: 10000 });
|
||||||
if (dateTo) filters.dateTo = dateTo;
|
|
||||||
|
|
||||||
// Get all orders with filters (no pagination for stats calculation)
|
// Apply client-side date filtering
|
||||||
const response = await this.getOrders(filters, { limit: 10000 });
|
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
|
||||||
const orders = response.list;
|
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats (parse string amounts from NocoDB)
|
||||||
const totalOrders = orders.length;
|
const totalOrders = orders.length;
|
||||||
const totalRevenue = orders.reduce((sum, order) => sum + (order.totalAmount || 0), 0);
|
const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount || '0'), 0);
|
||||||
const totalCO2Offset = orders.reduce((sum, order) => sum + (order.co2Tons || 0), 0);
|
const totalCO2Offset = orders.reduce((sum, order) => sum + parseFloat(order.co2Tons || '0'), 0);
|
||||||
|
|
||||||
const ordersByStatus = {
|
const ordersByStatus = {
|
||||||
pending: orders.filter((o) => o.status === 'pending').length,
|
pending: orders.filter((o) => o.status === 'pending').length,
|
||||||
@ -273,12 +318,11 @@ export class NocoDBClient {
|
|||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string
|
dateTo?: string
|
||||||
): Promise<Array<{ date: string; count: number; revenue: number }>> {
|
): Promise<Array<{ date: string; count: number; revenue: number }>> {
|
||||||
const filters: OrderFilters = {};
|
// Fetch all orders without date filtering
|
||||||
if (dateFrom) filters.dateFrom = dateFrom;
|
const response = await this.getOrders({}, { limit: 10000 });
|
||||||
if (dateTo) filters.dateTo = dateTo;
|
|
||||||
|
|
||||||
const response = await this.getOrders(filters, { limit: 10000 });
|
// Apply client-side date filtering
|
||||||
const orders = response.list;
|
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
|
||||||
|
|
||||||
// Group orders by time period
|
// Group orders by time period
|
||||||
const grouped = new Map<string, { count: number; revenue: number }>();
|
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 };
|
const existing = grouped.get(key) || { count: 0, revenue: 0 };
|
||||||
grouped.set(key, {
|
grouped.set(key, {
|
||||||
count: existing.count + 1,
|
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
|
* Get count of records matching filters
|
||||||
*/
|
*/
|
||||||
async getCount(filters: OrderFilters = {}): Promise<number> {
|
async getCount(filters: OrderFilters = {}): Promise<number> {
|
||||||
const whereClause = this.buildWhereClause(filters);
|
// Fetch all orders and count after client-side filtering
|
||||||
const params = whereClause ? `?where=${whereClause}` : '';
|
const response = await this.getOrders(filters, { limit: 10000 });
|
||||||
|
return response.list.length;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,7 @@ export default function AdminDashboard() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Revenue',
|
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} />,
|
icon: <DollarSign size={24} />,
|
||||||
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
|
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
|
||||||
},
|
},
|
||||||
@ -254,26 +254,8 @@ export default function AdminDashboard() {
|
|||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
label={(props: any) => {
|
label={false}
|
||||||
const RADIAN = Math.PI / 180;
|
outerRadius={100}
|
||||||
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}
|
|
||||||
fill="#8884d8"
|
fill="#8884d8"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,32 +1,231 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Orders</h1>
|
<div>
|
||||||
<p className="text-gray-600">View and manage all carbon offset orders</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder */}
|
{/* Stats Cards */}
|
||||||
<motion.div
|
<OrderStatsCards stats={stats} isLoading={isLoading && !stats} />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{/* Filters */}
|
||||||
className="glass-card p-12 text-center"
|
<OrderFilters
|
||||||
>
|
onSearchChange={handleSearchChange}
|
||||||
<Package size={48} className="mx-auto mb-4 text-gray-400" />
|
onStatusChange={handleStatusChange}
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Orders Management</h2>
|
onDateRangeChange={handleDateRangeChange}
|
||||||
<p className="text-gray-600 mb-4">
|
onReset={handleResetFilters}
|
||||||
Orders table with filtering, search, and export will be implemented in Phase 4.
|
searchValue={searchTerm}
|
||||||
</p>
|
statusValue={statusFilter}
|
||||||
<div className="inline-block px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm">
|
dateFromValue={dateFrom}
|
||||||
Backend API integration coming in Phase 2
|
dateToValue={dateTo}
|
||||||
</div>
|
/>
|
||||||
</motion.div>
|
|
||||||
</div>
|
{/* 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';
|
fuelUnit?: 'liters' | 'gallons';
|
||||||
|
|
||||||
// Custom amount
|
// Custom amount
|
||||||
customAmount?: number; // tons of CO2
|
customAmount?: number; // USD dollar amount for direct offsetting
|
||||||
|
|
||||||
// Vessel information (optional, for context)
|
// Vessel information (optional, for context)
|
||||||
vessel?: {
|
vessel?: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user