/** * plugins/excelExporter.js * * Xuất Excel từ DataTable với đầy đủ: * - Group header (2 dòng, colspan/rowspan merge) * - Stack split (array → nhiều sub-row, mỗi item 1 dòng) * - splitTemplate, splitExpression, splitField, splitType currency * - Template: FormatNumber, dayjs date, điều kiện row.type===x, Math.abs diff * - Lọc cột action (DebtCheckbox, SvgIcon, modal...) * - Màu header từ headerStyle / tablesetting * * Không cần cài thêm package — SheetJS load từ CDN lần đầu dùng (lazy). */ // ───────────────────────────────────────────────────────────────────────────── // Import SheetJS từ package đã cài (npm install xlsx) // ───────────────────────────────────────────────────────────────────────────── import * as _XLSX from 'xlsx' async function getXLSX() { return _XLSX } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function stripHtml(str) { if (!str) return '' return String(str).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() } function fmtCurrency(v) { if (v === null || v === undefined || v === '') return '' const n = Number(v) if (isNaN(n)) return String(v) return n.toLocaleString('vi-VN') } function fmtDate(raw, fmt = 'DD/MM/YYYY') { if (!raw) return '' try { const d = new Date(raw) const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') return fmt.replace('DD', dd).replace('MM', mm).replace('YYYY', String(d.getFullYear())) } catch { return String(raw) } } function resolveSplitTemplate(template, item) { return template.replace(/\{(\w+)\}/g, (_, key) => item[key] ?? '') } function evalSplitExpression(expression, item) { try { const expr = expression.replace(/\{(\w+)\}/g, (_, key) => { const v = item[key] return (v === null || v === undefined) ? 'null' : Number(v) }) // eslint-disable-next-line no-new-func return Function('"use strict"; return (' + expr + ')')() } catch { return null } } // ───────────────────────────────────────────────────────────────────────────── // Resolve text của 1 item trong stack array // ───────────────────────────────────────────────────────────────────────────── function getStackItemText(field, item) { if (field.splitExpression) { const val = evalSplitExpression(field.splitExpression, item) if (val === null || val === undefined) return '—' return field.splitType === 'currency' ? fmtCurrency(val) : String(val) } if (field.splitTemplate) return resolveSplitTemplate(field.splitTemplate, item) const val = item[field.splitField] if (val === null || val === undefined) return '—' return field.splitType === 'currency' ? fmtCurrency(val) : String(val) } // ───────────────────────────────────────────────────────────────────────────── // Resolve text của 1 cell bình thường // ───────────────────────────────────────────────────────────────────────────── function getCellText(field, row) { if (field.name === '__stt') return row.__stt ?? '' if (field.split === 'stack') { const arr = row[field.name] if (!Array.isArray(arr) || arr.length === 0) return '—' // Gom tất cả item thành 1 chuỗi ngăn cách bởi newline return arr.map(item => getStackItemText(field, item)).join('\n') } if (field.template) return evalTemplate(field.template, row) if (field.format === 'date' && row[field.name]) return fmtDate(row[field.name]) if (field.format === 'number' || field.type === 'currency') { return row[field.name] !== null && row[field.name] !== undefined ? fmtCurrency(row[field.name]) : '' } return row[field.name] ?? '' } function evalTemplate(template, row) { // const fnMatch = template.match(/FormatNumber[^}]*value:\s*row\.(\w+)/) if (fnMatch) { const v = row[fnMatch[1]] return v === null || v === undefined ? '' : fmtCurrency(v) } // row.type === N ? row.xxx : null const condMatch = template.match(/row\.(\w+)\s*===\s*(\d+)\s*\?\s*row\.(\w+)\s*:\s*null/) if (condMatch) { const v = row[condMatch[1]] === Number(condMatch[2]) ? row[condMatch[3]] : null return v === null || v === undefined ? '' : fmtCurrency(v) } // $dayjs(row.xxx).format('...') const djMatch = template.match(/\$dayjs\(row\.(\w+)\)\.format\(['"]([^'"]+)['"]\)/) if (djMatch) return fmtDate(row[djMatch[1]], djMatch[2]) // Math.abs($dayjs().startOf('day').diff(row.xxx, 'day')) const diffMatch = template.match(/Math\.abs\(.*\.diff\(row\.(\w+),\s*'day'\)\)/) if (diffMatch) { try { const d = new Date(row[diffMatch[1]]); d.setHours(0, 0, 0, 0) const today = new Date(); today.setHours(0, 0, 0, 0) return String(Math.abs(Math.round((today - d) / 86400000))) } catch { return '' } } // {{row.xxx}} nội suy đơn giản const interpolated = template.replace(/\{\{row\.(\w+)\}\}/g, (_, k) => row[k] ?? '') return stripHtml(interpolated) } // ───────────────────────────────────────────────────────────────────────────── // Xây dựng group header row (mirror DataTable.vue groupHeaderRow computed) // ───────────────────────────────────────────────────────────────────────────── function buildGroupHeaderRow(fields) { const result = [] let i = 0 while (i < fields.length) { const f = fields[i] if (!f.groupHeader) { result.push({ label: stripHtml(f.label), colspan: 1, rowspan: 2, isGroup: false }) i++ } else { let count = 1 while (i + count < fields.length && fields[i + count].groupHeader === f.groupHeader) count++ result.push({ label: f.groupHeader, colspan: count, rowspan: 1, isGroup: true }) i += count } } return result } // ───────────────────────────────────────────────────────────────────────────── // Style helpers // ───────────────────────────────────────────────────────────────────────────── function hexFromStyle(headerStyle) { const m = (headerStyle || '').match(/background-color:\s*#([0-9a-fA-F]{6})/i) return m ? m[1].toUpperCase() : '204853' } function thinBorder() { const s = { style: 'thin', color: { rgb: 'FFD0D0D0' } } return { top: s, bottom: s, left: s, right: s } } function headerCellStyle(bgHex, fgHex) { return { fill: { patternType: 'solid', fgColor: { rgb: 'FF' + bgHex } }, font: { bold: true, color: { rgb: 'FF' + fgHex }, sz: 10, name: 'Arial' }, alignment: { horizontal: 'center', vertical: 'center', wrapText: true }, border: thinBorder(), } } function dataCellStyle(alignRight, wrapText) { return { font: { sz: 10, name: 'Arial' }, alignment: { horizontal: alignRight ? 'right' : 'left', vertical: 'top', wrapText }, border: thinBorder(), } } // ───────────────────────────────────────────────────────────────────────────── // Hàm xuất Excel chính // ───────────────────────────────────────────────────────────────────────────── async function exportToExcel({ fields, data, tablesetting = [], filename = 'export' }) { const XLSX = await getXLSX() const hasGroup = fields.some(f => f.groupHeader) const HEADER_ROWS = hasGroup ? 2 : 1 const fgHex = (tablesetting.find(s => s.code === 'header-font-color')?.detail || '#FFFFFF') .replace('#', '').toUpperCase() // ── Xây dựng AOA ────────────────────────────────────────────────────────── const aoa = [] if (hasGroup) { const groupRow = buildGroupHeaderRow(fields) const row1 = [] const row2 = [] let colCursor = 0 groupRow.forEach(col => { if (!col.isGroup) { row1.push(col.label) row2.push('') // merge về row1 sau colCursor++ } else { row1.push(col.label) for (let x = 1; x < col.colspan; x++) row1.push('') const groupFields = fields.slice(colCursor, colCursor + col.colspan) groupFields.forEach(f => row2.push(stripHtml(f.label))) colCursor += col.colspan } }) aoa.push(row1) aoa.push(row2) } else { aoa.push(fields.map(f => stripHtml(f.label))) } // ── Dữ liệu ──────────────────────────────────────────────────────────────── data.forEach((row, idx) => { if (row.__stt === undefined) row.__stt = idx + 1 // Đếm số sub-row cần (bằng max length của các stack field) const maxStack = Math.max(1, ...fields.map(f => f.split === 'stack' && Array.isArray(row[f.name]) ? (row[f.name].length || 1) : 1 )) for (let si = 0; si < maxStack; si++) { aoa.push(fields.map(f => { // Cột stack: lấy item[si] if (f.split === 'stack') { const arr = Array.isArray(row[f.name]) ? row[f.name] : [] const item = arr[si] return item ? getStackItemText(f, item) : '' } // Cột thường: chỉ ghi ở sub-row đầu return si === 0 ? String(getCellText(f, row) ?? '') : '' })) } }) // ── Worksheet ────────────────────────────────────────────────────────────── const ws = XLSX.utils.aoa_to_sheet(aoa) // ── Merge cells ──────────────────────────────────────────────────────────── if (hasGroup) { const merges = [] const groupRow = buildGroupHeaderRow(fields) let c = 0 groupRow.forEach(col => { if (!col.isGroup) { merges.push({ s: { r: 0, c }, e: { r: 1, c } }) // rowspan=2 c++ } else { if (col.colspan > 1) merges.push({ s: { r: 0, c }, e: { r: 0, c: c + col.colspan - 1 } }) // colspan c += col.colspan } }) ws['!merges'] = merges } // ── Độ rộng cột ──────────────────────────────────────────────────────────── ws['!cols'] = fields.map(f => { const labelLen = stripHtml(f.label).length const minWch = Math.max(f.minwidth ? f.minwidth / 7 : 10, labelLen + 2) const maxWch = f.maxwidth ? f.maxwidth / 7 : 40 return { wch: Math.min(minWch, maxWch) } }) // ── Chiều cao dòng header ────────────────────────────────────────────────── ws['!rows'] = Array.from({ length: HEADER_ROWS }, () => ({ hpt: 36 })) // ── Style từng cell ──────────────────────────────────────────────────────── const wsRange = XLSX.utils.decode_range(ws['!ref']) for (let R = wsRange.s.r; R <= wsRange.e.r; R++) { for (let C = wsRange.s.c; C <= wsRange.e.c; C++) { const addr = XLSX.utils.encode_cell({ r: R, c: C }) if (!ws[addr]) ws[addr] = { t: 's', v: '' } const f = fields[C] if (R < HEADER_ROWS) { ws[addr].s = headerCellStyle(hexFromStyle(f?.headerStyle), fgHex) } else { const isRight = f?.textalign === 'right' || f?.splitType === 'currency' const hasStack = f?.split === 'stack' ws[addr].s = dataCellStyle(isRight, hasStack) } } } // ── Ghi file ─────────────────────────────────────────────────────────────── const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Data') XLSX.writeFile(wb, `${filename}.xlsx`) } // ───────────────────────────────────────────────────────────────────────────── // Nuxt Plugin // ───────────────────────────────────────────────────────────────────────────── export default defineNuxtPlugin(() => { return { provide: { exportTableExcel: exportToExcel, }, } })