Merge remote-tracking branch 'origin/iipbs/st/dev' into feature/review-skeleton-sein

This commit is contained in:
2026-02-09 16:19:09 +09:00
3 changed files with 231 additions and 32 deletions
+107 -13
View File
@@ -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<string, string> = 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<string, string>();
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);
});
// 데이터 행 읽기
@@ -692,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<string, string | number>[]) => void } | undefined;
if (dp?.setFields) {
dp.setFields(buildFieldsFromColumns(columns));
}
// setFields 호출 직후 행 데이터 적용 (realgrid-react의 setRows 타이밍 이슈 방지)
if (dp?.setRows && rowData?.length) {
dp.setRows(rowData);
}
}}
>
{columns.map((column) => (
+114 -18
View File
@@ -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<Record<string, string | number>[]>) => void,
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>,
setRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>,
sortDirRef: { current: Record<string, "asc" | "desc" | "none"> },
originalRowsRef: { current: Record<string, Record<string, string | number>[]> },
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<string, string> = 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<string, string>();
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);
});
// 데이터 행 읽기
@@ -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',
},
// 하수처리구역 내 > 합계
{