From 89df17aaeb3cec453cf27a8c9f68931dc3a12e3c Mon Sep 17 00:00:00 2001 From: rsone001 Date: Mon, 9 Feb 2026 10:11:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?st:=20fix:=20excel=20import=20&=20export=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EB=A7=9E=EC=B6=94=EA=B8=B0=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/components/module/DataGrid.tsx | 114 +++++++++++++-- apps/st/src/components/module/TabDataGrid.tsx | 132 +++++++++++++++--- .../input/SeweragePenetrationRateInpColumn.ts | 11 +- 3 files changed, 226 insertions(+), 31 deletions(-) diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 520a6c47f..71229fc04 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -3,7 +3,7 @@ import { ValueType } from "realgrid"; import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"; import { RealGridReact, RGDataColumn } from "realgrid-react"; -import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type"; +import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback, ColumnLayoutItem } from "../type/DataGrid.type"; import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"; import JSZip from "jszip"; import * as XLSX from "xlsx"; @@ -315,12 +315,94 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport .find((s) => s.menuEngNm === activeTabId) ?.menuKornNm || 'export'; + // 엑셀 import 시 매칭을 위해 헤더에 fieldName 포함 (예: "합계 (swgeProcZoneInnerSm)") + const originalHeaders: Map = new Map(); + columns.forEach((col) => { + if (col.fieldName && gridView.columnByName) { + const gridCol = gridView.columnByName(col.name); + if (gridCol && gridCol.header) { + const originalText = gridCol.header.text || ''; + originalHeaders.set(col.name, originalText); + const headerWithFieldName = `${originalText} (${col.fieldName})`; + gridCol.header.text = headerWithFieldName; + } + } + }); + + // exportGrid 호출 전에 columnLayout 정규화 (col 속성 추가) + const normalizeLayoutForExport = (layout: ColumnLayoutItem[]): ColumnLayoutItem[] => { + return layout.map(item => { + if (typeof item === 'string') { + return item; + } + if (item && typeof item === 'object') { + // { column: string, footerUserSpans?: ... } 형태인 경우 + if ('column' in item && typeof item.column === 'string') { + const colItem = item as { column: string; footerUserSpans?: unknown[] }; + // col 속성 추가 (RealGrid가 exportGrid에서 필요로 함) + return { + ...colItem, + col: gridView.columnByName?.(colItem.column) || null, + } as ColumnLayoutItem; + } + // { name: string, items: ... } 형태인 경우 + if ('items' in item && Array.isArray(item.items)) { + return { + ...item, + items: normalizeLayoutForExport(item.items as ColumnLayoutItem[]), + }; + } + } + return item; + }); + }; + + const originalLayout = columnLayout; + let normalizedLayout: ColumnLayoutItem[] | undefined; + let layoutRestored = false; + + // footerUserSpans가 있는 경우에만 정규화 + const checkFooterUserSpans = (layout: ColumnLayoutItem[]): boolean => { + return layout.some(item => { + if (typeof item === 'string') return false; + if (item && typeof item === 'object' && 'footerUserSpans' in item) return true; + if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) { + return checkFooterUserSpans(item.items as ColumnLayoutItem[]); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + normalizedLayout = normalizeLayoutForExport(columnLayout); + // 정규화된 layout을 임시로 설정 + gridView.setColumnLayout?.(normalizedLayout); + } + gridView.exportGrid({ type: 'excel', target: 'local', fileName: `${menuName}.xlsx`, footer: 'hidden', done: () => { + // 헤더 원복 + columns.forEach((col) => { + if (col.fieldName && gridView.columnByName) { + const gridCol = gridView.columnByName(col.name); + if (gridCol && gridCol.header && originalHeaders.has(col.name)) { + gridCol.header.text = originalHeaders.get(col.name) || ''; + } + } + }); + // columnLayout 원복 (footerUserSpans 유지) + if (normalizedLayout && !layoutRestored) { + layoutRestored = true; + setTimeout(() => { + if (gridRef.current?.gridView && originalLayout) { + gridRef.current.gridView.setColumnLayout?.(originalLayout); + } + }, 100); + } alert('내보내기가 완료되었습니다.'); }, }); @@ -390,19 +472,27 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; - // 헤더 텍스트를 fieldName으로 매핑하는 맵 생성 + // 엑셀 컬럼 인덱스 → 그리드 fieldName 매핑 const headerToFieldNameMap = new Map(); - headers.forEach((headerText) => { - // columns에서 header.text가 일치하는 컬럼 찾기 - const matchedColumn = columns.find( - (col) => col.header?.text === headerText || col.fieldName === headerText - ); - if (matchedColumn?.fieldName) { - headerToFieldNameMap.set(headerText, matchedColumn.fieldName); - } else { - // 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성) - headerToFieldNameMap.set(headerText, headerText); + headers.forEach((headerText, colIndex) => { + let fieldName: string | null = null; + + // 헤더에 fieldName이 포함된 경우 (예: "합계 (swgeProcZoneInnerSm)") + const fieldNameMatch = headerText.match(/\(([a-zA-Z_][a-zA-Z0-9_]*)\)$/); + if (fieldNameMatch && fieldNameMatch[1]) { + const extractedFieldName = fieldNameMatch[1]; + if (columns.find(c => c.fieldName === extractedFieldName)) { + fieldName = extractedFieldName; + } } + + // 매칭 실패 시 원본 헤더 텍스트 사용 + if (!fieldName) { + console.warn(`엑셀 컬럼 ${colIndex + 1} (헤더: "${headerText}") 매칭 실패 → 헤더 텍스트를 fieldName으로 사용`); + fieldName = headerText; + } + + headerToFieldNameMap.set(headerText, fieldName); }); // 데이터 행 읽기 diff --git a/apps/st/src/components/module/TabDataGrid.tsx b/apps/st/src/components/module/TabDataGrid.tsx index 16c0680be..532a342bd 100644 --- a/apps/st/src/components/module/TabDataGrid.tsx +++ b/apps/st/src/components/module/TabDataGrid.tsx @@ -3,7 +3,7 @@ import { ValueType } from "realgrid" import RealGrid, { GridFitStyle } from "realgrid" import { RealGridReact, RGDataColumn } from "realgrid-react" -import type { TabDataGridProps, ColumnInfo, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type" +import type { TabDataGridProps, ColumnInfo, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback, ColumnLayoutItem } from "../type/DataGrid.type" import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" import JSZip from "jszip" import * as XLSX from "xlsx" @@ -151,18 +151,18 @@ function updateHeaderSortIndicator( /** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */ function createOnSortingHandler( columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[], - setRowData: (updater: React.SetStateAction[]>) => void, - sortDirRef: React.MutableRefObject>, - originalRowsRef: React.MutableRefObject[]>>, + setRowData: React.Dispatch[]>>, + sortDirRef: { current: Record }, + originalRowsRef: { current: Record[]> }, findGridElement?: () => HTMLElement | null ) { const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))]; // mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용 // 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용 const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName); - const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0]; + const mergeCellCol = mergeCellCols.find((c) => c.fieldName === 'gugun') || mergeCellCols[0]; const groupField = mergeCellCol?.fieldName || null; - const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음 + const orderByField = 'rgnNm'; // 고정값 또는 설정 가능하게 할 수 있음 const hasMergeCells = mergeCellCols.length > 0; return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) { @@ -341,13 +341,101 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r ?.menuKornNm || 'export'; const columnInfoItem = columnInfo.find(info => info.tabKey === tabKey); - const tabLabel = columnInfoItem?.tabLabel || tabKey; + if (!columnInfoItem) return; + + const tabLabel = columnInfoItem.tabLabel || tabKey; + const columns = columnInfoItem.columns || []; + const columnLayout = columnInfoItem.columnLayout || []; + + // 엑셀 import 시 매칭을 위해 헤더에 fieldName 포함 (예: "합계 (swgeProcZoneInnerSm)") + const originalHeaders: Map = new Map(); + columns.forEach((col) => { + if (col.fieldName && gridView.columnByName) { + const gridCol = gridView.columnByName(col.name); + if (gridCol && gridCol.header) { + const originalText = gridCol.header.text || ''; + originalHeaders.set(col.name, originalText); + const headerWithFieldName = `${originalText} (${col.fieldName})`; + gridCol.header.text = headerWithFieldName; + } + } + }); + + // exportGrid 호출 전에 columnLayout 정규화 (col 속성 추가) + const normalizeLayoutForExport = (layout: ColumnLayoutItem[]): ColumnLayoutItem[] => { + return layout.map(item => { + if (typeof item === 'string') { + return item; + } + if (item && typeof item === 'object') { + // { column: string, footerUserSpans?: ... } 형태인 경우 + if ('column' in item && typeof item.column === 'string') { + const colItem = item as { column: string; footerUserSpans?: unknown[] }; + // col 속성 추가 (RealGrid가 exportGrid에서 필요로 함) + return { + ...colItem, + col: gridView.columnByName?.(colItem.column) || null, + } as ColumnLayoutItem; + } + // { name: string, items: ... } 형태인 경우 + if ('items' in item && Array.isArray(item.items)) { + return { + ...item, + items: normalizeLayoutForExport(item.items as ColumnLayoutItem[]), + }; + } + } + return item; + }); + }; + + const originalLayout = columnLayout; + let normalizedLayout: ColumnLayoutItem[] | undefined; + let layoutRestored = false; + + // footerUserSpans가 있는 경우에만 정규화 + const checkFooterUserSpans = (layout: ColumnLayoutItem[]): boolean => { + return layout.some(item => { + if (typeof item === 'string') return false; + if (item && typeof item === 'object' && 'footerUserSpans' in item) return true; + if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) { + return checkFooterUserSpans(item.items as ColumnLayoutItem[]); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + normalizedLayout = normalizeLayoutForExport(columnLayout); + // 정규화된 layout을 임시로 설정 + gridView.setColumnLayout?.(normalizedLayout); + } + gridView.exportGrid({ type: 'excel', target: 'local', fileName: `${menuName}_${tabLabel}.xlsx`, footer: 'hidden', done: () => { + // 헤더 원복 + columns.forEach((col) => { + if (col.fieldName && gridView.columnByName) { + const gridCol = gridView.columnByName(col.name); + if (gridCol && gridCol.header && originalHeaders.has(col.name)) { + gridCol.header.text = originalHeaders.get(col.name) || ''; + } + } + }); + // columnLayout 원복 (footerUserSpans 유지) + if (normalizedLayout && !layoutRestored) { + layoutRestored = true; + setTimeout(() => { + const currentGridRef = gridRefs.current.get(tabKey); + if (currentGridRef?.gridView && originalLayout) { + currentGridRef.gridView.setColumnLayout?.(originalLayout); + } + }, 100); + } alert('내보내기가 완료되었습니다.'); }, }); @@ -423,20 +511,28 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; - // 헤더 텍스트를 fieldName으로 매핑하는 맵 생성 + // 엑셀 컬럼 인덱스 → 그리드 fieldName 매핑 const headerToFieldNameMap = new Map(); const columns = columnInfoItem.columns || []; - headers.forEach((headerText) => { - // columns에서 header.text가 일치하는 컬럼 찾기 - const matchedColumn = columns.find( - (col) => col.header?.text === headerText || col.fieldName === headerText - ); - if (matchedColumn?.fieldName) { - headerToFieldNameMap.set(headerText, matchedColumn.fieldName); - } else { - // 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성) - headerToFieldNameMap.set(headerText, headerText); + headers.forEach((headerText, colIndex) => { + let fieldName: string | null = null; + + // 헤더에 fieldName이 포함된 경우 (예: "합계 (swgeProcZoneInnerSm)") + const fieldNameMatch = headerText.match(/\(([a-zA-Z_][a-zA-Z0-9_]*)\)$/); + if (fieldNameMatch && fieldNameMatch[1]) { + const extractedFieldName = fieldNameMatch[1]; + if (columns.find(c => c.fieldName === extractedFieldName)) { + fieldName = extractedFieldName; + } } + + // 매칭 실패 시 원본 헤더 텍스트 사용 + if (!fieldName) { + console.warn(`엑셀 컬럼 ${colIndex + 1} (헤더: "${headerText}") 매칭 실패 → 헤더 텍스트를 fieldName으로 사용`); + fieldName = headerText; + } + + headerToFieldNameMap.set(headerText, fieldName); }); // 데이터 행 읽기 diff --git a/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts b/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts index 182d2d50d..39d3c2234 100644 --- a/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts +++ b/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts @@ -35,7 +35,16 @@ export const SeweragePenetrationRateInpColumn = { { name: 'popltnSm', fieldName: 'popltnSm', header: { text: '총인구(명)' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, // 총면적 - { name: 'arSm', fieldName: 'arSm', header: { text: '총면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + { + name: 'arSm', + fieldName: 'arSm', + header: { text: '총면적(㎢)' }, + footer: footerDecimal3, + valueType: ValueType.NUMBER, + dataFormat: '#,##0.000', + computed: { fields: ['swgeProcZoneInnerAr', 'swgeProcZoneOuterAr'], type: 'sum' as const }, + styleName: 'rg-cell-total', + }, // 하수처리구역 내 > 합계 { From 226c61eee92817969854facfa142c82af37c5cab Mon Sep 17 00:00:00 2001 From: rsone001 Date: Mon, 9 Feb 2026 10:49:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?st:=20fix:=20setFields=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=A7=81=ED=9B=84=20=ED=96=89=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/components/module/DataGrid.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 71229fc04..b762ed5cb 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -782,10 +782,14 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport rows={rowData} onInitialized={(grid) => { (grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); - const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void; setRows?: (rows: Record[]) => void } | undefined; if (dp?.setFields) { dp.setFields(buildFieldsFromColumns(columns)); } + // setFields 호출 직후 행 데이터 적용 (realgrid-react의 setRows 타이밍 이슈 방지) + if (dp?.setRows && rowData?.length) { + dp.setRows(rowData); + } }} > {columns.map((column) => (