puffin-app/components/admin/ExportButton.tsx
Matt 4b408986e5
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m25s
Add complete admin portal implementation with orders management
- 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
2025-11-03 22:24:17 +01:00

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>
);
}