/** * Client-side .xlsx export with shared styling (headers, money format, alignment). * @see ./EXCEL_EXPORT_STANDARD.md — conventions for new Excel exports */ import * as XLSX from "xlsx-js-style"; /** Light grey header background + bold text (exported by xlsx-js-style). */ const HEADER_CELL_STYLE: XLSX.CellStyle = { font: { bold: true, color: { rgb: "000000" } }, fill: { patternType: "solid", fgColor: { rgb: "D9D9D9" }, }, alignment: { vertical: "center", horizontal: "center", wrapText: true }, }; function applyHeaderRowStyle( ws: XLSX.WorkSheet, columnCount: number ): void { for (let colIdx = 0; colIdx < columnCount; colIdx++) { const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx }); const cell = ws[cellRef]; if (cell) { cell.s = { ...HEADER_CELL_STYLE }; } } } /** Headers for money columns (GRN report & similar): thousands separator in Excel. */ const MONEY_COLUMN_HEADER = /金額|單價|Amount|Unit Price|Total Amount|Total Amount \/ 金額|Amount \/ 金額|Unit Price \/ 單價/i; /** Quantity / numeric columns (right-align with amounts). */ const QTY_COLUMN_HEADER = /Qty|數量|Demand/i; function isNumericDataColumnHeader(h: string): boolean { return MONEY_COLUMN_HEADER.test(h) || QTY_COLUMN_HEADER.test(h); } /** * Apply Excel number format `#,##0.00` to money columns so values show with comma separators. * Handles numeric cells and pre-formatted strings like "1,234.56". */ function applyMoneyColumnNumberFormats(ws: XLSX.WorkSheet, headerLabels: string[]): void { const moneyColIdx = new Set(); headerLabels.forEach((h, c) => { if (MONEY_COLUMN_HEADER.test(h)) moneyColIdx.add(c); }); if (moneyColIdx.size === 0 || !ws["!ref"]) return; const range = XLSX.utils.decode_range(ws["!ref"]); for (let r = range.s.r + 1; r <= range.e.r; r++) { moneyColIdx.forEach((c) => { const cellRef = XLSX.utils.encode_cell({ r, c }); const cell = ws[cellRef]; if (!cell) return; if (typeof cell.v === "number" && Number.isFinite(cell.v)) { cell.t = "n"; cell.z = "#,##0.00"; return; } if (typeof cell.v === "string") { const cleaned = cell.v.replace(/,/g, "").trim(); if (cleaned === "") return; const n = Number.parseFloat(cleaned); if (!Number.isNaN(n)) { cell.v = n; cell.t = "n"; cell.z = "#,##0.00"; } } }); } } /** Right-align amount / quantity column headers and data (merge with existing header fill). */ function applyNumericColumnRightAlign( ws: XLSX.WorkSheet, headerLabels: string[] ): void { const numericColIdx = new Set(); headerLabels.forEach((h, c) => { if (isNumericDataColumnHeader(h)) numericColIdx.add(c); }); if (numericColIdx.size === 0 || !ws["!ref"]) return; const range = XLSX.utils.decode_range(ws["!ref"]); for (let r = range.s.r; r <= range.e.r; r++) { numericColIdx.forEach((c) => { const cellRef = XLSX.utils.encode_cell({ r, c }); const cell = ws[cellRef]; if (!cell) return; const prev = (cell.s || {}) as XLSX.CellStyle; cell.s = { ...prev, font: prev.font, fill: prev.fill, border: prev.border, numFmt: prev.numFmt, alignment: { ...prev.alignment, horizontal: "right", vertical: "center", wrapText: prev.alignment?.wrapText ?? true, }, }; }); } } /** * Export an array of row objects to a .xlsx file and trigger download. * @param rows Array of objects (keys become column headers) * @param filename Download filename (without .xlsx) * @param sheetName Optional sheet name (default "Sheet1") */ export function exportChartToXlsx( rows: Record[], filename: string, sheetName = "Sheet1" ): void { if (rows.length === 0) { const ws = XLSX.utils.aoa_to_sheet([[]]); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, sheetName); XLSX.writeFile(wb, `${filename}.xlsx`); return; } const ws = XLSX.utils.json_to_sheet(rows); // Auto-set column widths based on header length (simple heuristic). const header = Object.keys(rows[0] ?? {}); if (header.length > 0) { ws["!cols"] = header.map((h) => ({ // Basic width: header length + padding, minimum 12 wch: Math.max(12, h.length + 4), })); applyHeaderRowStyle(ws, header.length); applyMoneyColumnNumberFormats(ws, header); applyNumericColumnRightAlign(ws, header); } const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, sheetName); XLSX.writeFile(wb, `${filename}.xlsx`); } export type MultiSheetSpec = { name: string; rows: Record[] }; /** * Export multiple worksheets in one .xlsx file. * Sheet names are truncated to 31 characters (Excel limit). */ export function exportMultiSheetToXlsx( sheets: MultiSheetSpec[], filename: string ): void { const wb = XLSX.utils.book_new(); for (const { name, rows } of sheets) { const safeName = name.slice(0, 31); let ws: ReturnType; if (rows.length === 0) { ws = XLSX.utils.aoa_to_sheet([[]]); } else { ws = XLSX.utils.json_to_sheet(rows); const header = Object.keys(rows[0] ?? {}); if (header.length > 0) { ws["!cols"] = header.map((h) => ({ wch: Math.max(12, h.length + 4), })); applyHeaderRowStyle(ws, header.length); applyMoneyColumnNumberFormats(ws, header); applyNumericColumnRightAlign(ws, header); } } XLSX.utils.book_append_sheet(wb, ws, safeName); } XLSX.writeFile(wb, `${filename}.xlsx`); }