|
- /**
- * 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<number>();
- 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<number>();
- 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<string, unknown>[],
- 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<string, unknown>[] };
-
- /**
- * 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<typeof XLSX.utils.json_to_sheet>;
- 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`);
- }
|