297 lines
8.9 KiB
TypeScript
297 lines
8.9 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|