287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
import {
|
|
FaCheckSquare,
|
|
FaDownload,
|
|
FaFileExcel,
|
|
FaFileExport,
|
|
FaFilePdf,
|
|
FaSpinner,
|
|
FaSquare,
|
|
FaTimes,
|
|
} from 'react-icons/fa';
|
|
|
|
export interface QueryReportColumn {
|
|
fieldname: string;
|
|
label?: string;
|
|
}
|
|
|
|
export type QueryReportExportFormat = 'excel' | 'pdf';
|
|
|
|
export interface QueryReportExportModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title?: string;
|
|
columns: QueryReportColumn[];
|
|
rows: Record<string, unknown>[];
|
|
fileNamePrefix?: string;
|
|
}
|
|
|
|
function formatCellValue(value: unknown): string {
|
|
if (value === null || value === undefined || value === '') return '';
|
|
return String(value);
|
|
}
|
|
|
|
function downloadExcel(
|
|
rows: Record<string, unknown>[],
|
|
columns: QueryReportColumn[],
|
|
fileName: string,
|
|
) {
|
|
const wsData = [
|
|
columns.map((c) => c.label || c.fieldname),
|
|
...rows.map((row) => columns.map((c) => formatCellValue(row[c.fieldname]))),
|
|
];
|
|
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Export');
|
|
XLSX.writeFile(wb, fileName);
|
|
}
|
|
|
|
function downloadPdf(
|
|
rows: Record<string, unknown>[],
|
|
columns: QueryReportColumn[],
|
|
reportTitle: string,
|
|
) {
|
|
const printWindow = window.open('', '_blank');
|
|
if (!printWindow) {
|
|
window.alert('Please allow popups for this site to export PDF.');
|
|
return;
|
|
}
|
|
|
|
const tableHTML = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>${reportTitle}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
h1 { text-align: center; color: #333; margin-bottom: 8px; font-size: 18px; }
|
|
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 11px; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 10px; }
|
|
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; vertical-align: top; }
|
|
th { background: #047857; color: white; }
|
|
tr:nth-child(even) { background: #f9fafb; }
|
|
@media print { body { margin: 0; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${reportTitle}</h1>
|
|
<div class="meta">
|
|
Generated on: ${new Date().toLocaleString()} | Total Records: ${rows.length}
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
${columns.map((c) => `<th>${c.label || c.fieldname}</th>`).join('')}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows.map((row) => `
|
|
<tr>
|
|
${columns.map((c) => `<td>${formatCellValue(row[c.fieldname]) || '—'}</td>`).join('')}
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
<script>window.onload = function() { window.print(); }</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
printWindow.document.write(tableHTML);
|
|
printWindow.document.close();
|
|
}
|
|
|
|
const QueryReportExportModal: React.FC<QueryReportExportModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
title = 'Export Report',
|
|
columns,
|
|
rows,
|
|
fileNamePrefix = 'report',
|
|
}) => {
|
|
const [format, setFormat] = useState<QueryReportExportFormat>('excel');
|
|
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
const columnOptions = useMemo(
|
|
() => columns.filter((c) => c.fieldname),
|
|
[columns],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
setFormat('excel');
|
|
setCheckedKeys(new Set(columnOptions.map((c) => c.fieldname)));
|
|
}, [isOpen, columnOptions]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const chosenColumns = columnOptions.filter((c) => checkedKeys.has(c.fieldname));
|
|
const canExport = rows.length > 0 && chosenColumns.length > 0;
|
|
|
|
const toggleColumn = (key: string) => {
|
|
setCheckedKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const selectAll = () => setCheckedKeys(new Set(columnOptions.map((c) => c.fieldname)));
|
|
const selectNone = () => setCheckedKeys(new Set());
|
|
|
|
const handleExport = async () => {
|
|
if (!canExport) return;
|
|
setIsExporting(true);
|
|
try {
|
|
const datePart = new Date().toISOString().split('T')[0];
|
|
if (format === 'excel') {
|
|
downloadExcel(rows, chosenColumns, `${fileNamePrefix}_${datePart}.xlsx`);
|
|
} else {
|
|
downloadPdf(rows, chosenColumns, title);
|
|
}
|
|
onClose();
|
|
} catch (err) {
|
|
console.error('Export failed:', err);
|
|
window.alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden">
|
|
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 px-5 py-4 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<FaFileExport className="text-white" />
|
|
<h3 className="text-base font-semibold text-white">{title}</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isExporting}
|
|
className="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20"
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-5 space-y-5 overflow-y-auto flex-1">
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">File format</h4>
|
|
<div className="flex gap-3">
|
|
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer ${
|
|
format === 'excel'
|
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
|
: 'border-gray-200 dark:border-gray-700'
|
|
}`}>
|
|
<input
|
|
type="radio"
|
|
name="wo_feedback_export_format"
|
|
checked={format === 'excel'}
|
|
onChange={() => setFormat('excel')}
|
|
className="text-emerald-600"
|
|
/>
|
|
<FaFileExcel className="text-green-700" />
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">Excel (.xlsx)</span>
|
|
</label>
|
|
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer ${
|
|
format === 'pdf'
|
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
|
: 'border-gray-200 dark:border-gray-700'
|
|
}`}>
|
|
<input
|
|
type="radio"
|
|
name="wo_feedback_export_format"
|
|
checked={format === 'pdf'}
|
|
onChange={() => setFormat('pdf')}
|
|
className="text-emerald-600"
|
|
/>
|
|
<FaFilePdf className="text-red-600" />
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">PDF</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
Columns to export
|
|
</h4>
|
|
<div className="flex gap-3 text-xs text-emerald-600 dark:text-emerald-400">
|
|
<button type="button" onClick={selectAll} className="hover:underline">All</button>
|
|
<button type="button" onClick={selectNone} className="hover:underline">None</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-52 overflow-y-auto p-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
{columnOptions.map((col) => {
|
|
const checked = checkedKeys.has(col.fieldname);
|
|
return (
|
|
<button
|
|
key={col.fieldname}
|
|
type="button"
|
|
onClick={() => toggleColumn(col.fieldname)}
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-colors ${
|
|
checked
|
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-200'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{checked
|
|
? <FaCheckSquare size={13} className="text-emerald-600 shrink-0" />
|
|
: <FaSquare size={13} className="text-gray-300 shrink-0" />}
|
|
<span className="truncate" title={col.label || col.fieldname}>
|
|
{col.label || col.fieldname}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-1.5">
|
|
{checkedKeys.size} of {columnOptions.length} columns selected · {rows.length} row{rows.length !== 1 ? 's' : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isExporting}
|
|
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleExport}
|
|
disabled={!canExport || isExporting}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
{isExporting ? (
|
|
<><FaSpinner className="animate-spin" size={14} /> Exporting…</>
|
|
) : (
|
|
<><FaDownload size={14} /> Export</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default QueryReportExportModal;
|