diff --git a/api/nocodbClient.ts b/api/nocodbClient.ts index 369e777..a499e42 100644 --- a/api/nocodbClient.ts +++ b/api/nocodbClient.ts @@ -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> { 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>(endpoint); + const response = await this.request>(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 { - 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> { - 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(); @@ -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 { - 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; } } diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 844f74f..7326c95 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -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: , 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 ( - cx ? 'start' : 'end'} - dominantBaseline="central" - > - {`${name}: ${(percent * 100).toFixed(0)}%`} - - ); - }} - outerRadius={80} + label={false} + outerRadius={100} fill="#8884d8" dataKey="value" > diff --git a/app/admin/orders/page.tsx b/app/admin/orders/page.tsx index a7f7874..f1bd07b 100644 --- a/app/admin/orders/page.tsx +++ b/app/admin/orders/page.tsx @@ -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([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [totalCount, setTotalCount] = useState(0); + const [sortKey, setSortKey] = useState('CreatedAt'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [selectedOrder, setSelectedOrder] = useState(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 ( +
+

Orders

+
+

{error}

+ +
+
+ ); + } + return ( -
+ {/* Header */} -
-

Orders

-

View and manage all carbon offset orders

+
+
+

Orders

+

+ View and manage all carbon offset orders +

+
+
- {/* Placeholder */} - - -

Orders Management

-

- Orders table with filtering, search, and export will be implemented in Phase 4. -

-
- Backend API integration coming in Phase 2 -
-
-
+ {/* Stats Cards */} + + + {/* Filters */} + + + {/* Orders Table */} + + + {/* Order Details Modal */} + +
); } diff --git a/components/admin/ExportButton.tsx b/components/admin/ExportButton.tsx new file mode 100644 index 0000000..6939aa0 --- /dev/null +++ b/components/admin/ExportButton.tsx @@ -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 ( +
+ + + {/* Dropdown Menu */} + + {showDropdown && ( + <> + {/* Backdrop to close dropdown */} + setShowDropdown(false)} + /> + + {/* Dropdown */} + + + + + + )} + +
+ ); +} diff --git a/components/admin/OrderDetailsModal.tsx b/components/admin/OrderDetailsModal.tsx new file mode 100644 index 0000000..f859ce3 --- /dev/null +++ b/components/admin/OrderDetailsModal.tsx @@ -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(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 }) => ( + + ); + + const DetailField = ({ + label, + value, + copyable = false, + fieldName = '', + }: { + label: string; + value: string | number | undefined | null; + copyable?: boolean; + fieldName?: string; + }) => { + const displayValue = value || 'N/A'; + return ( +
+
{label}
+
+
{displayValue}
+ {copyable && value && } +
+
+ ); + }; + + const SectionHeader = ({ title }: { title: string }) => ( +

+
+ {title} +

+ ); + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Sliding Panel */} + + {/* Header */} +
+
+

Order Details

+

+ ID: {order.orderId.substring(0, 12)}... +

+
+ +
+ + {/* Scrollable Content */} +
+ {/* Order Information */} +
+ +
+ +
+
Status
+ + {order.status.charAt(0).toUpperCase() + order.status.slice(1)} + +
+ + + +
+
+ + {/* Payment Details */} +
+ +
+ + + + + {order.amountUSD && ( + + )} + + + + +
+
+ + {/* Customer Information */} +
+ +
+ + + + {order.businessName && ( + <> + + + + + )} +
+
+ + {/* Billing Address */} +
+ +
+ + + + + + +
+
+ + {/* Carbon Offset Details */} +
+ +
+
+
CO₂ Offset
+ + {parseFloat(order.co2Tons).toFixed(2)} tons + +
+ + + + {order.certificateUrl && ( +
+
+ Certificate URL +
+ + View Certificate + + +
+ )} + +
+
+ + {/* Vessel Information (if applicable) */} + {(order.vesselName || order.imoNumber || order.vesselType || order.vesselLength) && ( +
+ +
+ + + + +
+
+ )} + + {/* Trip Details (if applicable) */} + {(order.departurePort || + order.arrivalPort || + order.distance || + order.avgSpeed || + order.duration || + order.enginePower) && ( +
+ +
+ + + + + + +
+
+ )} + + {/* Admin Notes */} + {order.notes && ( +
+ +
+

{order.notes}

+
+
+ )} +
+ + {/* Footer */} +
+ +
+
+ + )} +
+ ); +} diff --git a/components/admin/OrderFilters.tsx b/components/admin/OrderFilters.tsx new file mode 100644 index 0000000..2631ac9 --- /dev/null +++ b/components/admin/OrderFilters.tsx @@ -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 ( +
+
+ {/* Search Input */} +
+ + 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" + /> +
+ + {/* Status Filter */} +
+ + +
+ + + +
+
+ + {/* Date Range Picker */} +
+ + + {/* Date Picker Dropdown */} + {showDatePicker && ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+
+ )} +
+ + {/* Reset Filters Button */} + {hasActiveFilters && ( + + )} +
+ + {/* Active Filters Display */} + {hasActiveFilters && ( +
+ Active filters: + {searchValue && ( + + Search: "{searchValue}" + + + )} + {statusValue && ( + + Status: {statusValue.charAt(0).toUpperCase() + statusValue.slice(1)} + + + )} + {(dateFromValue || dateToValue) && ( + + Date: {dateFromValue || '...'} to {dateToValue || '...'} + + + )} +
+ )} +
+ ); +} diff --git a/components/admin/OrderStatsCards.tsx b/components/admin/OrderStatsCards.tsx new file mode 100644 index 0000000..34c0688 --- /dev/null +++ b/components/admin/OrderStatsCards.tsx @@ -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: , + 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: , + 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: , + 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: , + gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600', + }, + ] + : []; + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+ {statCards.map((stat, index) => ( + +
+
+ {stat.icon} +
+
+

{stat.title}

+

{stat.value}

+
+ ))} +
+ ); +} diff --git a/components/admin/OrdersTable.tsx b/components/admin/OrdersTable.tsx new file mode 100644 index 0000000..01efcac --- /dev/null +++ b/components/admin/OrdersTable.tsx @@ -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 ; + } + return sortOrder === 'asc' ? ( + + ) : ( + + ); + }; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (orders.length === 0) { + return ( +
+
+
📦
+

No Orders Found

+

There are no orders matching your criteria.

+
+
+ ); + } + + return ( +
+ {/* Table */} +
+ + + + + + + + + + + + + + {orders.map((order, index) => ( + + + + + + + + + + ))} + +
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" + > +
+ Order ID + {getSortIcon('orderId')} +
+
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" + > +
+ Customer + {getSortIcon('customerName')} +
+
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" + > +
+ Amount + {getSortIcon('totalAmount')} +
+
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" + > +
+ CO₂ Offset + {getSortIcon('co2Tons')} +
+
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" + > +
+ Status + {getSortIcon('status')} +
+
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" + > +
+ Created + {getSortIcon('CreatedAt')} +
+
+ Actions +
+
+ {order.orderId ? `${order.orderId.substring(0, 8)}...` : 'N/A'} +
+
+
{order.customerName}
+
{order.customerEmail}
+
+
+ {formatCurrency(order.totalAmount, order.currency)} +
+
+ + {parseFloat(order.co2Tons).toFixed(2)} tons + + + + {order.status ? order.status.charAt(0).toUpperCase() + order.status.slice(1) : 'Unknown'} + + + {formatDate(order.CreatedAt)} + + +
+
+ + {/* Pagination */} +
+
+
+ Rows per page: + +
+ +
+ Showing {(currentPage - 1) * pageSize + 1} to{' '} + {Math.min(currentPage * pageSize, totalCount)} of {totalCount} orders +
+ +
+ + +
+ {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 ( + + ); + })} +
+ + +
+
+
+
+ ); +} diff --git a/src/types.ts b/src/types.ts index a9726d6..20ac9cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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?: {