From fbe355f6cfcb8596ed36db9e380f7a174f54db88 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 9 Feb 2026 15:52:00 +0900 Subject: [PATCH] =?UTF-8?q?[st]=20feat:=20DataGrid=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=20=ED=96=89=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=84=A4=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 | 819 +++++++++++++----- .../VerificationArticleManagement.tsx | 46 +- apps/st/src/components/type/DataGrid.type.ts | 167 +++- 3 files changed, 738 insertions(+), 294 deletions(-) diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index b762ed5cb..adf9f9c72 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -3,7 +3,13 @@ import { ValueType } from "realgrid"; import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"; import { RealGridReact, RGDataColumn } from "realgrid-react"; -import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback, ColumnLayoutItem } 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"; @@ -18,7 +24,7 @@ declare module "realgrid" { interface GridView { setOptions(options: { footer?: { visible?: boolean }; - summaryMode?: 'aggregate' | 'statistical' | 'none'; + summaryMode?: "aggregate" | "statistical" | "none"; checkBar?: { visible?: boolean }; }): void; } @@ -36,16 +42,25 @@ if (typeof window !== "undefined") { window.JSZip = JSZip; } -RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ''); +RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ""); /** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */ interface RealGridDataProviderComparer { - setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void; + setDataComparer?( + field: string | number, + comparer: ( + field: string | number, + row1: number, + row2: number, + ) => number, + ): void; getValue?(row: number, field: string | number): unknown; } /** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ -function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number { +function createNumberComparer( + dp: RealGridDataProviderComparer, +): (field: string | number, row1: number, row2: number) => number { return (field, row1, row2) => { const get = dp.getValue?.bind(dp); if (!get) return 0; @@ -73,23 +88,33 @@ function toNum(v: unknown): number { /** 헤더에 정렬 화살표 표시 (CSS 방식) */ function updateHeaderSortIndicator( gridView: { - columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined; - columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined; - getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; + columnByIndex?: ( + i: number, + ) => { fieldName?: string; name?: string } | undefined; + columnByName?: ( + name: string, + ) => { fieldName?: string; name?: string } | undefined; + getColumn?: ( + i: number, + ) => { fieldName?: string; name?: string } | undefined; }, columns: { fieldName?: string; name: string }[], - sortDir: Record + sortDir: Record, ) { try { columns.forEach((col) => { if (!col.fieldName && !col.name) return; - + // 컬럼 찾기 let gridCol: { fieldName?: string; name?: string } | undefined; for (let i = 0; i < 100; i++) { - const c = gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i); + const c = + gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i); if (!c) break; - if (c.fieldName === (col.fieldName || col.name) || c.name === (col.fieldName || col.name)) { + if ( + c.fieldName === (col.fieldName || col.name) || + c.name === (col.fieldName || col.name) + ) { gridCol = c; break; } @@ -97,42 +122,55 @@ function updateHeaderSortIndicator( if (!gridCol) { gridCol = gridView.columnByName?.(col.name); } - + if (!gridCol) return; - - const fieldName = gridCol.fieldName || gridCol.name || col.fieldName || col.name; + + const fieldName = + gridCol.fieldName || gridCol.name || col.fieldName || col.name; const direction = sortDir[fieldName] || sortDir[col.name] || "none"; - + // DOM에서 헤더 요소 찾기 - const gridEl1 = document.querySelector('[data-grid-id]') as HTMLElement | null; - const gridEl2 = document.querySelector('.rg-grid') as HTMLElement | null; + const gridEl1 = document.querySelector( + "[data-grid-id]", + ) as HTMLElement | null; + const gridEl2 = document.querySelector( + ".rg-grid", + ) as HTMLElement | null; const gridEl = gridEl1 || gridEl2; if (!gridEl) return; - + // 헤더 셀 찾기 (fieldName 또는 name으로) - const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"]'); + const headerCells = gridEl.querySelectorAll( + '.rg-header-cell, [class*="header"]', + ); let targetCell: HTMLElement | null = null; - + headerCells.forEach((cell) => { const el = cell as HTMLElement; const text = el.textContent?.trim(); const colName = col.name; const colFieldName = col.fieldName; - if (text && (text.includes(colName) || (colFieldName && text.includes(colFieldName)))) { + if ( + text && + (text.includes(colName) || + (colFieldName && text.includes(colFieldName))) + ) { targetCell = el; } }); - + if (!targetCell) return; - + const headerCell = targetCell as HTMLElement; - + // 기존 화살표 제거 - const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null; + const existingIndicator = headerCell.querySelector( + ".rg-sort-indicator", + ) as HTMLElement | null; if (existingIndicator) { existingIndicator.remove(); } - + // 화살표 추가 if (direction === "asc" || direction === "desc") { const indicator = document.createElement("span"); @@ -153,48 +191,84 @@ function updateHeaderSortIndicator( /** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */ function createOnSortingHandler( - columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[], - setRowData: React.Dispatch[]>>, + columns: { + fieldName?: string; + name: string; + mergeCells?: boolean; + sortdisable?: boolean; + valueType?: ValueType; + }[], + setRowData: React.Dispatch< + React.SetStateAction[]> + >, sortDirRef: { current: Record }, - originalRowsRef: { current: Record[]> } + originalRowsRef: { + current: Record[]>; + }, ) { - const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))]; + 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 === 'gugun') || mergeCellCols[0]; + const mergeCellCols = columns.filter( + (c) => c.mergeCells === true && c.fieldName, + ); + const mergeCellCol = + mergeCellCols.find((c) => c.fieldName === "gugun") || mergeCellCols[0]; const groupField = mergeCellCol?.fieldName || null; - const orderByField = 'rgnNm'; // 고정값 또는 설정 가능하게 할 수 있음 + const orderByField = "rgnNm"; // 고정값 또는 설정 가능하게 할 수 있음 const hasMergeCells = mergeCellCols.length > 0; - - return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) { - const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } }; + + return function onSorting( + g: unknown, + fields: unknown[], + _directions: unknown[], + ) { + const grid = g as { + getColumn?: ( + i: number, + ) => { fieldName?: string; name?: string } | undefined; + getDataSource?: () => { + getRowCount(): number; + getValue(r: number, f: string): unknown; + }; + }; const idx = fields?.[0]; if (idx === undefined) return true; let sortField: string | null = null; if (typeof idx === "string") sortField = idx; else { const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0); - sortField = (colObj?.fieldName ?? colObj?.name) ?? null; + sortField = colObj?.fieldName ?? colObj?.name ?? null; } if (!sortField) return true; - const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField)); + const col = columns.find( + (c) => c.fieldName === sortField || c.name === sortField, + ); if (!col || col.sortdisable) return true; - + // mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용 if (!hasMergeCells || !groupField) return true; - + const ds = grid.getDataSource?.(); if (!ds) return true; const cnt = ds.getRowCount(); const rows: Record[] = []; for (let i = 0; i < cnt; i++) { const r: Record = {}; - for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number; + for (const f of fieldNames) + r[f] = ds.getValue(i, f) as string | number; rows.push(r); } const prev = sortDirRef.current[sortField] ?? "none"; - const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds); + const setRows = ( + ds as { setRows?: (r: unknown[]) => void } + ).setRows?.bind(ds); if (prev === "desc") { const orig = originalRowsRef.current[sortField]; @@ -210,7 +284,13 @@ function createOnSortingHandler( } const sortAsc = prev !== "asc"; sortDirRef.current[sortField] = sortAsc ? "asc" : "desc"; - const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType); + const sorted = sortRowsGroupPreserving( + rows, + sortField, + sortAsc, + { groupField, orderByField }, + col.valueType, + ); setRows?.(sorted); setRowData(sorted); return false; @@ -223,7 +303,7 @@ function sortRowsGroupPreserving( sortField: string, sortAsc: boolean, opt: { groupField: string; orderByField?: string }, - valueType?: ValueType + valueType?: ValueType, ): Record[] { const groups = new Map[]>(); for (const r of rows) { @@ -231,7 +311,7 @@ function sortRowsGroupPreserving( if (!groups.has(k)) groups.set(k, []); groups.get(k)!.push(r); } - + // valueType에 따라 비교 함수 선택 const compareValue = (val: unknown): number | string => { if (valueType === ValueType.NUMBER) { @@ -240,22 +320,28 @@ function sortRowsGroupPreserving( // 텍스트나 기타 타입은 문자열로 비교 return String(val ?? ""); }; - + const rep = (arr: Record[]): number | string => { const values = arr.map((r) => compareValue(r[sortField])); if (valueType === ValueType.NUMBER) { - const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v)); + const nums = values.filter( + (v): v is number => typeof v === "number" && !isNaN(v), + ); if (nums.length === 0) return sortAsc ? Infinity : -Infinity; return sortAsc ? Math.min(...nums) : Math.max(...nums); } else { // 문자열 정렬 - const strs = values.filter((v): v is string => typeof v === "string"); + const strs = values.filter( + (v): v is string => typeof v === "string", + ); if (strs.length === 0) return sortAsc ? "\uffff" : ""; strs.sort(); - return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string); + return sortAsc + ? (strs[0] as string) + : (strs[strs.length - 1] as string); } }; - + const entries = [...groups.entries()].sort((a, b) => { const valA = rep(a[1]); const valB = rep(b[1]); @@ -267,41 +353,81 @@ function sortRowsGroupPreserving( const strB = String(valB); return (sortAsc ? 1 : -1) * strA.localeCompare(strB); }); - + return entries.flatMap(([, arr]) => arr); } /** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ -function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { +function buildFieldsFromColumns( + columns: { + fieldName: string; + valueType?: ValueType; + computed?: import("../type/DataGrid.type").ComputedFieldConfig; + }[], +) { return columns.map((col) => { - const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + const field: { + fieldName: string; + dataType: string; + valueCallback?: ( + prod: unknown, + dataRow: number, + fieldName: string, + fieldNames: string[], + values: unknown[], + ) => number; + } = { fieldName: col.fieldName, dataType: col.valueType === ValueType.NUMBER ? "number" : "text", }; if (col.computed) { - const hasFields = "fields" in col.computed && col.computed.fields.length > 0; - const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + const hasFields = + "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = + "numeratorFields" in col.computed && + col.computed.numeratorFields.length > 0 && + col.computed.denominatorFields.length > 0; if (hasFields || hasNumeratorDenominator) { - field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + field.valueCallback = getComputedValueCallback( + col.computed, + ) as ( + prod: unknown, + dataRow: number, + fieldName: string, + fieldNames: string[], + values: unknown[], + ) => number; } } return field; }); } -const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { +const DataGrid = ({ + columnInfo, + rowStartNum, + isExcelImport = true, + isExcelExport = true, + fixedColumns, + showHeaderCheckbox = false, + rowData, + setRowData, + onRowClick, +}: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); const fileInputRef = useRef(null); const sortDirRef = useRef>({}); - const originalRowsRef = useRef[]>>({}); + const originalRowsRef = useRef< + Record[]> + >({}); const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); const menuList = useTabStore((state) => state.menuList); const exportExcel = () => { if (rowData.length === 0) { - alert('데이터가 없습니다.'); + alert("데이터가 없습니다."); return; } @@ -309,11 +435,12 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const gridView = gridRef.current.gridView; - const menuName = menuList - ?.flatMap((m) => m.children || []) - .flatMap((subM) => subM || []) - .find((s) => s.menuEngNm === activeTabId) - ?.menuKornNm || 'export'; + const menuName = + menuList + ?.flatMap((m) => m.children || []) + .flatMap((subM) => subM || []) + .find((s) => s.menuEngNm === activeTabId)?.menuKornNm || + "export"; // 엑셀 import 시 매칭을 위해 헤더에 fieldName 포함 (예: "합계 (swgeProcZoneInnerSm)") const originalHeaders: Map = new Map(); @@ -321,7 +448,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport if (col.fieldName && gridView.columnByName) { const gridCol = gridView.columnByName(col.name); if (gridCol && gridCol.header) { - const originalText = gridCol.header.text || ''; + const originalText = gridCol.header.text || ""; originalHeaders.set(col.name, originalText); const headerWithFieldName = `${originalText} (${col.fieldName})`; gridCol.header.text = headerWithFieldName; @@ -330,26 +457,34 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }); // exportGrid 호출 전에 columnLayout 정규화 (col 속성 추가) - const normalizeLayoutForExport = (layout: ColumnLayoutItem[]): ColumnLayoutItem[] => { - return layout.map(item => { - if (typeof item === 'string') { + const normalizeLayoutForExport = ( + layout: ColumnLayoutItem[], + ): ColumnLayoutItem[] => { + return layout.map((item) => { + if (typeof item === "string") { return item; } - if (item && typeof item === 'object') { + if (item && typeof item === "object") { // { column: string, footerUserSpans?: ... } 형태인 경우 - if ('column' in item && typeof item.column === 'string') { - const colItem = item as { column: string; footerUserSpans?: unknown[] }; + 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, + col: + gridView.columnByName?.(colItem.column) || null, } as ColumnLayoutItem; } // { name: string, items: ... } 형태인 경우 - if ('items' in item && Array.isArray(item.items)) { + if ("items" in item && Array.isArray(item.items)) { return { ...item, - items: normalizeLayoutForExport(item.items as ColumnLayoutItem[]), + items: normalizeLayoutForExport( + item.items as ColumnLayoutItem[], + ), }; } } @@ -363,11 +498,23 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport // 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 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; }); @@ -380,17 +527,22 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport } gridView.exportGrid({ - type: 'excel', - target: 'local', + type: "excel", + target: "local", fileName: `${menuName}.xlsx`, - footer: 'hidden', + 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) || ''; + if ( + gridCol && + gridCol.header && + originalHeaders.has(col.name) + ) { + gridCol.header.text = + originalHeaders.get(col.name) || ""; } } }); @@ -399,11 +551,13 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport layoutRestored = true; setTimeout(() => { if (gridRef.current?.gridView && originalLayout) { - gridRef.current.gridView.setColumnLayout?.(originalLayout); + gridRef.current.gridView.setColumnLayout?.( + originalLayout, + ); } }, 100); } - alert('내보내기가 완료되었습니다.'); + alert("내보내기가 완료되었습니다."); }, }); }; @@ -422,21 +576,24 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport try { const data = event.target?.result; if (!data) { - alert('파일을 읽을 수 없습니다.'); + alert("파일을 읽을 수 없습니다."); return; } // SheetJS로 엑셀 파일을 JSON으로 변환 - const workbook = XLSX.read(data, { type: 'array', cellDates: true }); + const workbook = XLSX.read(data, { + type: "array", + cellDates: true, + }); const firstSheetName = workbook.SheetNames[0]; - const worksheet = workbook.Sheets[firstSheetName || '']; + const worksheet = workbook.Sheets[firstSheetName || ""]; - if (!worksheet || !worksheet['!ref']) { - alert('워크시트를 읽을 수 없습니다.'); + if (!worksheet || !worksheet["!ref"]) { + alert("워크시트를 읽을 수 없습니다."); return; } - const range = XLSX.utils.decode_range(worksheet['!ref']); + const range = XLSX.utils.decode_range(worksheet["!ref"]); const headerRowCount = rowStartNum || 1; // 기본값 1 const headerStartRow = Math.max(0, headerRowCount - 1); // 0-based index @@ -445,13 +602,28 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const headerRowIndexes: number[] = []; for (let col = range.s.c; col <= range.e.c; col++) { - let headerValue = ''; + let headerValue = ""; let headerRowIndex = headerStartRow; // rowStartNum 행부터 시작해서 위로 올라가면서 헤더 찾기 - for (let rowIndex = headerStartRow; rowIndex >= 0; rowIndex--) { - const cell = worksheet[XLSX.utils.encode_cell({ r: rowIndex, c: col })]; - if (cell && cell.v !== undefined && cell.v !== null && cell.v !== '') { + for ( + let rowIndex = headerStartRow; + rowIndex >= 0; + rowIndex-- + ) { + const cell = + worksheet[ + XLSX.utils.encode_cell({ + r: rowIndex, + c: col, + }) + ]; + if ( + cell && + cell.v !== undefined && + cell.v !== null && + cell.v !== "" + ) { headerValue = String(cell.v); headerRowIndex = rowIndex; break; @@ -469,29 +641,40 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport } // 실제 데이터가 시작하는 행 찾기 (헤더가 있는 최대 행 번호 + 1) - const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; + const maxHeaderRowIndex = + headerRowIndexes.length > 0 + ? Math.max(...headerRowIndexes) + : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; // 엑셀 컬럼 인덱스 → 그리드 fieldName 매핑 const headerToFieldNameMap = new Map(); headers.forEach((headerText, colIndex) => { let fieldName: string | null = null; - + // 헤더에 fieldName이 포함된 경우 (예: "합계 (swgeProcZoneInnerSm)") - const fieldNameMatch = headerText.match(/\(([a-zA-Z_][a-zA-Z0-9_]*)\)$/); + 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)) { + if ( + columns.find( + (c) => c.fieldName === extractedFieldName, + ) + ) { fieldName = extractedFieldName; } } - + // 매칭 실패 시 원본 헤더 텍스트 사용 if (!fieldName) { - console.warn(`엑셀 컬럼 ${colIndex + 1} (헤더: "${headerText}") 매칭 실패 → 헤더 텍스트를 fieldName으로 사용`); + console.warn( + `엑셀 컬럼 ${colIndex + 1} (헤더: "${headerText}") 매칭 실패 → 헤더 텍스트를 fieldName으로 사용`, + ); fieldName = headerText; } - + headerToFieldNameMap.set(headerText, fieldName); }); @@ -501,23 +684,46 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const rowData: Record = {}; let hasData = false; - for (let colIndex = 0; colIndex < headers.length; colIndex++) { + for ( + let colIndex = 0; + colIndex < headers.length; + colIndex++ + ) { const col = range.s.c + colIndex; - const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })]; - const headerName = headers[colIndex] || `Column${colIndex + 1}`; + const cell = + worksheet[ + XLSX.utils.encode_cell({ r: row, c: col }) + ]; + const headerName = + headers[colIndex] || `Column${colIndex + 1}`; // 헤더 텍스트를 fieldName으로 변환 - const fieldName = headerToFieldNameMap.get(headerName) || headerName; + const fieldName = + headerToFieldNameMap.get(headerName) || + headerName; - let cellValue: string | number = ''; - if (cell && cell.v !== undefined && cell.v !== null) { + let cellValue: string | number = ""; + if ( + cell && + cell.v !== undefined && + cell.v !== null + ) { // 엑셀 셀 타입이 숫자이거나 값이 숫자인 경우 숫자로 변환 - if (cell.t === 'n' || (typeof cell.v === 'number' && !isNaN(cell.v))) { + if ( + cell.t === "n" || + (typeof cell.v === "number" && + !isNaN(cell.v)) + ) { cellValue = cell.v as number; } else { const stringValue = String(cell.v); // 문자열이지만 숫자로 변환 가능한 경우 숫자로 변환 - const numericValue = Number(stringValue.replace(/,/g, '')); - if (!isNaN(numericValue) && stringValue.trim() !== '') { + const numericValue = Number( + stringValue.replace(/,/g, ""), + ); + if ( + !isNaN(numericValue) && + stringValue.trim() !== "" + ) { cellValue = numericValue; } else { cellValue = stringValue; @@ -525,7 +731,11 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport } } - if (cellValue !== '' && cellValue !== null && cellValue !== undefined) { + if ( + cellValue !== "" && + cellValue !== null && + cellValue !== undefined + ) { hasData = true; } @@ -539,49 +749,57 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport } setRowData(dataRows); } catch (error) { - console.error('엑셀 파일 파싱 오류:', error); - alert('엑셀 파일 파싱 중 오류가 발생했습니다.'); + console.error("엑셀 파일 파싱 오류:", error); + alert("엑셀 파일 파싱 중 오류가 발생했습니다."); } finally { if (fileInputRef.current) { - fileInputRef.current.value = ''; + fileInputRef.current.value = ""; } } }; reader.onerror = () => { - alert('파일 읽기 중 오류가 발생했습니다.'); + alert("파일 읽기 중 오류가 발생했습니다."); if (fileInputRef.current) { - fileInputRef.current.value = ''; + fileInputRef.current.value = ""; } }; reader.readAsArrayBuffer(file); } catch (error) { - console.error('엑셀 파일 가져오기 오류:', error); - alert('엑셀 파일 가져오기 중 오류가 발생했습니다.'); + console.error("엑셀 파일 가져오기 오류:", error); + alert("엑셀 파일 가져오기 중 오류가 발생했습니다."); if (fileInputRef.current) { - fileInputRef.current.value = ''; + fileInputRef.current.value = ""; } } }; useEffect(() => { - if (!gridRef.current?.gridView || !gridRef.current?.dataProvider) return; + if (!gridRef.current?.gridView || !gridRef.current?.dataProvider) + return; const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 const hasMergeCells = columns.some((col) => col.mergeCells === true); - + // setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리) const dp = dataProvider as RealGridDataProviderComparer; - if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") { + if ( + typeof dp.setDataComparer === "function" && + typeof dp.getValue === "function" + ) { const numberComparer = createNumberComparer(dp); const seen = new Set(); columns.forEach((col) => { if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리 - if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) { + if ( + col.valueType === ValueType.NUMBER && + col.fieldName && + !seen.has(col.fieldName) + ) { seen.add(col.fieldName); try { dp.setDataComparer!(col.fieldName, numberComparer); @@ -593,9 +811,19 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport } // mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용 - (gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + ( + gridView as { + onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean; + } + ).onSorting = createOnSortingHandler( + columns, + setRowData, + sortDirRef, + originalRowsRef, + ); - (gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN; + (gridView.displayOptions as DisplayOptions).fitStyle = + GridFitStyle.EVEN; gridView.header.height = 60; // 헤더 체크박스 표시 여부 설정 @@ -605,23 +833,34 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }); // footer가 있는 컬럼이 있으면 footer 높이 설정 - const hasFooter = columns.some(col => col.footer); + const hasFooter = columns.some((col) => col.footer); if (hasFooter) { // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) - const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); - const hasMultiRowFooter = columns.some(col => { + const hasArrayFooter = columns.some( + (col) => col.footer && Array.isArray(col.footer), + ); + const hasMultiRowFooter = columns.some((col) => { const footer = col.footer; - return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true; + return ( + footer && + !Array.isArray(footer) && + "multiRow" in footer && + footer.multiRow === true + ); }); if (hasArrayFooter || hasMultiRowFooter) { // footer 배열의 길이를 확인하여 행 수 결정 const arrayFooterLengths = columns - .filter(col => col.footer && Array.isArray(col.footer)) - .map(col => (col.footer as unknown[]).length); - const maxFooterLength = arrayFooterLengths.length > 0 - ? Math.max(...arrayFooterLengths) - : 2; // 기본값 2 - const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 })); + .filter((col) => col.footer && Array.isArray(col.footer)) + .map((col) => (col.footer as unknown[]).length); + const maxFooterLength = + arrayFooterLengths.length > 0 + ? Math.max(...arrayFooterLengths) + : 2; // 기본값 2 + const footerHeights = Array.from( + { length: maxFooterLength }, + () => ({ height: 40 }), + ); gridView.setFooters?.(footerHeights); } else { gridView.footer.height = 40; @@ -636,21 +875,31 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport } gridView.setOptions({ - summaryMode: "aggregate" + summaryMode: "aggregate", }); // footerUserSpans가 있는 경우 setColumnLayout으로 설정 const checkFooterUserSpans = (layout: unknown[]): 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 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); } return false; }); }; - + if (checkFooterUserSpans(columnLayout)) { // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { @@ -665,154 +914,266 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport setTimeout(() => { const currentGridView = gridRef.current?.gridView; if (!currentGridView || !currentGridView.columnByName) { - console.warn('GridView not available for footer setup'); + console.warn("GridView not available for footer setup"); return; } - + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 - const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + const hasArrayFooter = columns.some( + (col) => col.footer && Array.isArray(col.footer), + ); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { - const colInfo: RealGridColumnInfo = { - name: col.name, - fieldName: col.fieldName, - header: col.header, - editable: col.editable, - valueType: col.valueType, - dataFormat: col.dataFormat, - ...(col.styleName != null && { styleName: col.styleName }), - ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), - }; - - if (col.footer) { - if (Array.isArray(col.footer)) { - colInfo.footer = col.footer; - } else { - colInfo.footer = { - text: col.footer.text, - expression: col.footer.expression, - numberFormat: col.footer.numberFormat, - }; + const columnsWithFooter: RealGridColumnInfo[] = columns.map( + (col) => { + const colInfo: RealGridColumnInfo = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + ...(col.styleName != null && { + styleName: col.styleName, + }), + ...(col.mergeCells && { + mergeRule: { criteria: "value" as const }, + }), + }; + + if (col.footer) { + if (Array.isArray(col.footer)) { + colInfo.footer = col.footer; + } else { + colInfo.footer = { + text: col.footer.text, + expression: col.footer.expression, + numberFormat: col.footer.numberFormat, + }; + } } - } - - return colInfo; - }); - + + return colInfo; + }, + ); + try { - (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); - + (currentGridView as GridViewWithSetColumns).setColumns?.( + columnsWithFooter, + ); + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { - columns.forEach(col => { - if (!col.footer || !Array.isArray(col.footer)) return; - + columns.forEach((col) => { + if (!col.footer || !Array.isArray(col.footer)) + return; + try { - const column = currentGridView.columnByName(col.name); + const column = currentGridView.columnByName( + col.name, + ); if (!column) return; - + // footer 배열의 각 요소에 valueCallback 설정 col.footer.forEach((footerItem, index) => { - if (footerItem.valueCallback && column.footer) { + if ( + footerItem.valueCallback && + column.footer + ) { // footer가 배열인 경우 - if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; + if ( + Array.isArray(column.footer) && + column.footer[index] + ) { + ( + column.footer[ + index + ] as FooterItemWithCallback + ).valueCallback = + footerItem.valueCallback; } } }); } catch (e) { - console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e); + console.warn( + `Error setting footer array valueCallback for column ${col.name}:`, + e, + ); } }); }, 50); } catch (e) { - console.warn('Failed to set columns with footer array:', e); + console.warn("Failed to set columns with footer array:", e); } } - + // footer가 단일 객체이고 valueCallback이 있는 경우 - columns.forEach(col => { + columns.forEach((col) => { if (!col.footer || Array.isArray(col.footer)) return; - + try { const column = currentGridView.columnByName(col.name); if (!column || !column.footer) return; - + if (col.footer.valueCallback) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + ( + column.footer as { + valueCallback?: typeof col.footer.valueCallback; + } + ).valueCallback = col.footer.valueCallback; } } catch (e) { - console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + console.warn( + `Error setting footer valueCallback for column ${col.name}:`, + e, + ); } }); }, 200); - }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]); + // 행 클릭 이벤트 설정 + useEffect(() => { + if (!gridRef.current?.gridView || !onRowClick) return; + + const gridView = gridRef.current.gridView; + + // onCellClicked 이벤트 핸들러 설정 + gridView.onCellClicked = (grid, clickData) => { + // 데이터 셀만 처리 (헤더, 푸터 등 제외) + if ( + clickData.cellType === "data" && + clickData.itemIndex !== undefined + ) { + try { + // 행 전체 데이터 가져오기 + const rowData = grid.getValues(clickData.itemIndex); + + // 부모 컴포넌트로 데이터 전달 + onRowClick(rowData as Record); + } catch (error) { + console.error("행 데이터 가져오기 오류:", error); + } + } + }; + + // cleanup: 컴포넌트 언마운트 시 이벤트 핸들러 제거 + return () => { + if (gridView.onCellClicked) { + gridView.onCellClicked = undefined; + } + }; + }, [onRowClick]); return ( -
+
-
- {isExcelImport && - - } - {isExcelExport && - - } + )}
- { - (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; setRows?: (rows: Record[]) => void } | undefined; - if (dp?.setFields) { - dp.setFields(buildFieldsFromColumns(columns)); + { + ( + grid as { + onSorting?: ( + g: unknown, + f: unknown[], + d: unknown[], + ) => boolean; } - // setFields 호출 직후 행 데이터 적용 (realgrid-react의 setRows 타이밍 이슈 방지) - if (dp?.setRows && rowData?.length) { - dp.setRows(rowData); - } - }} - > + ).onSorting = createOnSortingHandler( + columns, + setRowData, + sortDirRef, + originalRowsRef, + ); + 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) => ( ))} @@ -822,4 +1183,4 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport ); }; -export default DataGrid; \ No newline at end of file +export default DataGrid; diff --git a/apps/st/src/components/statistics/verification/VerificationArticleManagement.tsx b/apps/st/src/components/statistics/verification/VerificationArticleManagement.tsx index a09a5cbb6..e55028b64 100644 --- a/apps/st/src/components/statistics/verification/VerificationArticleManagement.tsx +++ b/apps/st/src/components/statistics/verification/VerificationArticleManagement.tsx @@ -70,17 +70,38 @@ const VerificationArticleManagement = () => { console.log("등록"); }; + // 행 클릭 이벤트 핸들러 + const handleRowClick = (rowData: Record) => { + console.log("클릭한 행 데이터:", rowData); + // 여기에 필요한 로직 추가 (예: 상세 정보 표시, 수정 모달 열기 등) + }; + return ( - + {/* 탭 리스트 */} - H-B - I-MR - Validation + + H-B + + + I-MR + + + Validation +
-
+