import { type Table } from "@tanstack/react-table" import ExcelJS from "exceljs" /** * `exportTableToExcel`: * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) * - onlySelected: 선택된 행만 내보낼지 여부 * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) * - allPages: true일 경우, 페이징 상관없이 모든 행을 내보냄 * * 추가: * - i18n: (key: string) => string | undefined * => excelHeader나 group 값이 'myKey'처럼 i18n 키라면 이 함수를 통해 번역 문자열 반환 * - customHeaders: { [colId: string]: string } * => 특정 col.id에 대해 강제로 헤더를 지정하고 싶을 때 사용 */ export async function exportTableToExcel( table: Table, { filename = "table", onlySelected = false, excludeColumns = [], useGroupHeader = true, allPages = false, /** 아래 2개가 새로 추가된 옵션 */ i18n, customHeaders = {}, }: { filename?: string onlySelected?: boolean excludeColumns?: string[] useGroupHeader?: boolean allPages?: boolean /** excelHeader나 group 값이 i18n 키일 경우, 해당 함수를 통해 번역 */ i18n?: (key: string) => string /** 특정 col.id에 대한 강제 헤더 지정 */ customHeaders?: Record } = {} ): Promise { // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기 const allColumns = table.getAllLeafColumns() // 2) excludeColumns 목록에 들어있는 col.id 제거 const columns = allColumns.filter( (col) => !excludeColumns.includes(col.id) ) // 실제로 기록할 sheetData(배열 형식) let sheetData: any[][] // ────────────── 2줄 헤더 (group + excelHeader) vs 1줄 헤더 ────────────── if (useGroupHeader) { // 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) const row1: string[] = [] const row2: string[] = [] columns.forEach((col) => { const meta = col.columnDef.meta as any // 1) group (그룹헤더) const groupKey = meta?.group let groupLabel = groupKey ?? "" if (groupLabel && i18n) { // groupKey가 i18n 키라면 번역 적용 const maybeTranslated = i18n(groupLabel) if (maybeTranslated) { groupLabel = maybeTranslated } } row1.push(groupLabel) // 2) excelHeader (실제 컬럼 헤더) // (a) customHeaders[col.id]가 우선 if (customHeaders[col.id]) { row2.push(customHeaders[col.id]) } else { // (b) meta?.excelHeader가 있으면 그것을 사용 const maybeExcelHeader = meta?.excelHeader if (typeof maybeExcelHeader === "string") { // i18n 함수가 있다면 i18n 키로 가정하고 번역 시도 if (i18n) { const maybeTranslated = i18n(maybeExcelHeader) row2.push(maybeTranslated || maybeExcelHeader) } else { row2.push(maybeExcelHeader) } } else { // 모두 없으면 col.id 사용 row2.push(col.id) } } }) // ───────────────────────────────────────────────── // 필요한 데이터 행 추출 const rowModel = onlySelected ? table.getFilteredSelectedRowModel() : allPages ? table.getPrePaginationRowModel() : table.getRowModel() const dataRows = rowModel.rows.map((row) => columns.map((col) => { const val = row.getValue(col.id) if (val == null) return "" return typeof val === "object" ? JSON.stringify(val) : val }) ) // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] sheetData = [row1, row2, ...dataRows] } else { // ────────────── 기존 1줄 헤더 ────────────── const headerRow = columns.map((col) => { const meta = col.columnDef.meta as any // 1) customHeaders[col.id]가 우선 if (customHeaders[col.id]) { return customHeaders[col.id] } // 2) meta?.excelHeader가 문자열이면 if (typeof meta?.excelHeader === "string") { if (i18n) { const maybeTranslated = i18n(meta.excelHeader) return maybeTranslated || meta.excelHeader } else { return meta.excelHeader } } // 3) 모든 것이 없으면 col.id return col.id }) // 데이터 const rowModel = onlySelected ? table.getFilteredSelectedRowModel() : allPages ? table.getPrePaginationRowModel() : table.getRowModel() const dataRows = rowModel.rows.map((row) => columns.map((col) => { const val = row.getValue(col.id) if (val == null) return "" return typeof val === "object" ? JSON.stringify(val) : val }) ) sheetData = [headerRow, ...dataRows] } // ────────────── ExcelJS 워크북/시트 생성 ────────────── const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet("Sheet1") // (추가) 칼럼별 최대 길이 추적 const maxColumnLengths = columns.map(() => 0) sheetData.forEach((row) => { row.forEach((cellValue, colIdx) => { const cellText = cellValue?.toString() ?? "" if (cellText.length > maxColumnLengths[colIdx]) { maxColumnLengths[colIdx] = cellText.length } }) }) // 시트에 데이터 추가 + 헤더 스타일 sheetData.forEach((arr, idx) => { const row = worksheet.addRow(arr) // 헤더 스타일 적용 if (useGroupHeader) { // 2줄 헤더 if (idx < 2) { row.font = { bold: true } row.alignment = { horizontal: "center" } row.eachCell((cell) => { cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFCCCCCC" }, } }) } } else { // 1줄 헤더 if (idx === 0) { row.font = { bold: true } row.alignment = { horizontal: "center" } row.eachCell((cell) => { cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFCCCCCC" }, } }) } } }) // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── if (useGroupHeader) { // row1 (인덱스 1) = 그룹명 행 // row2 (인덱스 2) = 실제 컬럼 헤더 행 const groupRowIndex = 1 const groupRow = worksheet.getRow(groupRowIndex) // 같은 값이 연속되는 열을 병합 let start = 1 // 시작 열 인덱스 (1-based) let prevValue = groupRow.getCell(start).value for (let c = 2; c <= columns.length; c++) { const cellVal = groupRow.getCell(c).value if (cellVal !== prevValue) { // 이전 그룹명이 빈 문자열이 아니면 병합 if (prevValue && prevValue.toString().trim() !== "") { worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) } // 다음 구간 시작 start = c prevValue = cellVal } } // 마지막 구간까지 병합 if (prevValue && prevValue.toString().trim() !== "") { worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) } } // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── maxColumnLengths.forEach((len, idx) => { // 최소 너비 10, +2 여백 worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) }) // ────────────── 최종 파일 다운로드 ────────────── const buffer = await workbook.xlsx.writeBuffer() const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }) const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = `${filename}.xlsx` link.click() URL.revokeObjectURL(url) }