xpeditis2.0/apps/frontend/src/components/ExportButton.tsx
2026-08-13 17:43:11 +02:00

251 lines
8.7 KiB
TypeScript

/**
* Export Button Component
*
* Reusable component for exporting data to CSV or Excel
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import { useToast } from '@/components/ui/toast';
interface ExportButtonProps<T> {
data: T[];
filename: string;
columns: {
key: keyof T | string;
label: string;
format?: (value: any, row: T) => string;
}[];
disabled?: boolean;
}
export default function ExportButton<T extends Record<string, any>>({
data,
filename,
columns,
disabled = false,
}: ExportButtonProps<T>) {
const { toast } = useToast();
const t = useTranslations('components.exportButton');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const { hasFeature } = useSubscription();
const canExport = hasFeature('csv_export');
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? t('boolean.true') : t('boolean.false');
if (value instanceof Date) return value.toLocaleDateString(dateLocale);
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const getNestedValue = (obj: T, path: string): any => {
return path.split('.').reduce((acc, part) => acc?.[part], obj as any);
};
const generateCSV = (): string => {
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
const rows = data.map(row => {
return columns
.map(col => {
const value = getNestedValue(row, col.key as string);
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
return `"${formattedValue.replace(/"/g, '""')}"`;
})
.join(';');
});
return [headers, ...rows].join('\n');
};
const downloadFile = (content: string, mimeType: string, extension: string) => {
const BOM = '\uFEFF';
const blob = new Blob([BOM + content], { type: `${mimeType};charset=utf-8` });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}_${new Date().toISOString().split('T')[0]}.${extension}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleExportCSV = async () => {
setIsExporting(true);
try {
const csv = generateCSV();
downloadFile(csv, 'text/csv', 'csv');
} catch (error) {
console.error('Export CSV error:', error);
toast.error(t('errorCsv'));
} finally {
setIsExporting(false);
setIsOpen(false);
}
};
const handleExportExcel = async () => {
setIsExporting(true);
try {
const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles>
<Style ss:ID="Header">
<Font ss:Bold="1" ss:Color="#FFFFFF"/>
<Interior ss:Color="#3B82F6" ss:Pattern="Solid"/>
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
</Style>
<Style ss:ID="Default">
<Alignment ss:Vertical="Center"/>
</Style>
</Styles>
<Worksheet ss:Name="Export">
<Table>`;
const columnWidths = columns
.map(() => '<Column ss:AutoFitWidth="1" ss:Width="120"/>')
.join('');
const headerRow = `<Row ss:StyleID="Header">
${columns.map(col => `<Cell><Data ss:Type="String">${escapeXml(col.label)}</Data></Cell>`).join('')}
</Row>`;
const dataRows = data
.map(row => {
const cells = columns
.map(col => {
const value = getNestedValue(row, col.key as string);
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
const type = typeof value === 'number' ? 'Number' : 'String';
return `<Cell ss:StyleID="Default"><Data ss:Type="${type}">${escapeXml(formattedValue)}</Data></Cell>`;
})
.join('');
return `<Row>${cells}</Row>`;
})
.join('');
const xmlFooter = `</Table>
</Worksheet>
</Workbook>`;
const xmlContent = xmlHeader + columnWidths + headerRow + dataRows + xmlFooter;
downloadFile(xmlContent, 'application/vnd.ms-excel', 'xls');
} catch (error) {
console.error('Export Excel error:', error);
toast.error(t('errorExcel'));
} finally {
setIsExporting(false);
setIsOpen(false);
}
};
const escapeXml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))}
disabled={disabled || data.length === 0 || isExporting}
title={!canExport ? t('lockedTooltip') : undefined}
// Action secondaire : meme gabarit que la variante `outline` de Button,
// avec laquelle il se cotoie dans les en-tetes de page.
className={`inline-flex h-10 items-center rounded-lg border border-border bg-white px-6 text-body-sm font-medium transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
canExport ? 'text-brand-navy' : 'text-neutral-400'
}`}
>
{isExporting ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-neutral-600"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('exporting')}
</>
) : (
<>
{canExport ? <Download className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('label')}
{canExport && <ChevronDown className="ml-2 h-4 w-4" />}
</>
)}
</button>
{/* Dropdown Menu */}
{isOpen && !isExporting && canExport && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-border z-50">
<div className="py-1">
<button
onClick={handleExportCSV}
className="flex items-center w-full px-4 py-2.5 text-sm text-neutral-700 hover:bg-neutral-50 transition-colors"
>
<FileText className="mr-3 h-4 w-4 text-green-600" />
<div className="text-left">
<div className="font-medium">{t('csvTitle')}</div>
<div className="text-xs text-neutral-500">{t('csvSubtitle')}</div>
</div>
</button>
<button
onClick={handleExportExcel}
className="flex items-center w-full px-4 py-2.5 text-sm text-neutral-700 hover:bg-neutral-50 transition-colors"
>
<FileSpreadsheet className="mr-3 h-4 w-4 text-brand-navy" />
<div className="text-left">
<div className="font-medium">{t('excelTitle')}</div>
<div className="text-xs text-neutral-500">{t('excelSubtitle')}</div>
</div>
</button>
</div>
<div className="border-t border-border px-4 py-2">
<p className="text-xs text-neutral-500">{t('rowsToExport', { count: data.length })}</p>
</div>
</div>
)}
</div>
);
}