251 lines
8.7 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|