From 68c5548252b51c4091c676f7a8ced26c94d01999 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Thu, 29 Jan 2026 10:23:05 +0900 Subject: [PATCH] =?UTF-8?q?st:=20refactor:=20-=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A0=84=EC=B2=B4=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20-=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20sort=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20foooter=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20-=20menu=20active=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/app/(with-base-layout)/layout.tsx | 54 +- .../button/NotApplicableCheckButton.tsx | 2 +- .../src/components/module/CompactDataGrid.tsx | 219 ++++++-- apps/st/src/components/module/DataGrid.tsx | 422 ++++++++++++-- apps/st/src/components/module/TabDataGrid.tsx | 522 +++++++++++++++--- .../input/ExcretaTreatmentFacilityInp.tsx | 3 +- .../input/PrivateSewageFacilityInp.tsx | 3 +- .../input/PublicSewageFacilityInp.tsx | 3 +- .../statistics/input/PumpingStationInp.tsx | 3 +- .../input/SewageExcretaRelatedInp.tsx | 3 +- .../input/SewageExcretaSlurgeInp.tsx | 3 +- .../statistics/input/SewagePipeInp.tsx | 3 +- .../input/SewagePipeMaintainInp.tsx | 3 +- .../input/SewageReservoirPumpInp.tsx | 3 +- .../statistics/input/SewerageFinanceInp.tsx | 3 +- .../input/SeweragePenetrationRateInp.tsx | 6 +- .../input/SewerageServiceLevelInp.tsx | 3 +- .../statistics/input/StatsInputStatusInp.tsx | 5 +- .../statistics/input/WaterReuseInp.tsx | 3 +- .../management/NotEnteredStatus.tsx | 2 +- .../management/StatisticsInputManagement.tsx | 2 +- .../StatisticsPopulationManagement.tsx | 2 +- .../management/StatisticsReviewHistory.tsx | 2 +- .../review/ExcretaTreatmentFacilityRvw.tsx | 2 +- .../review/PrivateSewageFacilityRvw.tsx | 2 +- .../review/PublicSewageFacilityRvw.tsx | 2 +- .../statistics/review/PumpingStationRvw.tsx | 2 +- .../review/SewageExcretaRelatedRvw.tsx | 2 +- .../review/SewageExcretaSlurgeRvw.tsx | 2 +- .../review/SewagePipeMaintainRvw.tsx | 3 +- .../statistics/review/SewagePipeRvw.tsx | 4 +- .../review/SewageReservoirPumpRvw.tsx | 2 +- .../statistics/review/SewerageFinanceRvw.tsx | 2 +- .../review/SeweragePenetrationRateRvw.tsx | 5 +- .../review/SewerageServiceLevelRvw.tsx | 2 +- .../statistics/review/StatsInputStatusRvw.tsx | 18 +- .../statistics/review/WaterReuseRvw.tsx | 16 +- .../total/ExcretaTreatmentFacilityTot.tsx | 2 +- .../total/PrivateSewageFacilityTot.tsx | 2 +- .../total/PublicSewageFacilityTot.tsx | 2 +- .../statistics/total/PumpingStationTot.tsx | 2 +- .../total/SewageExcretaRelatedTot.tsx | 2 +- .../total/SewageExcretaSlurgeTot.tsx | 2 +- .../total/SewagePipeMaintainTot.tsx | 2 +- .../statistics/total/SewagePipeTot.tsx | 2 +- .../total/SewageReservoirPumpTot.tsx | 2 +- .../statistics/total/SewerageFinanceTot.tsx | 2 +- .../total/SeweragePenetrationRateTot.tsx | 2 +- .../total/SewerageServiceLevelTot.tsx | 2 +- .../statistics/total/WaterReuseTot.tsx | 2 +- .../ManagementVerificationRule.tsx | 2 +- apps/st/src/components/type/DataGrid.type.ts | 14 +- .../util/column/review/SewagePipeRvwColumn.ts | 11 +- .../SeweragePenetrationRateRvwColumn.ts | 180 +++++- .../src/components/util/grid/footerSummary.ts | 99 +++- packages/rsone/.gitignore | 5 +- .../rsone/components/grid/CompactDataGrid.tsx | 219 ++++++-- packages/rsone/components/grid/DataGrid.tsx | 455 ++++++++++++--- .../rsone/components/grid/TabDataGrid.tsx | 522 +++++++++++++++--- .../rsone/components/type/DataGrid.type.ts | 25 +- 60 files changed, 2430 insertions(+), 466 deletions(-) diff --git a/apps/st/app/(with-base-layout)/layout.tsx b/apps/st/app/(with-base-layout)/layout.tsx index 3b79033ad..3fe61855e 100644 --- a/apps/st/app/(with-base-layout)/layout.tsx +++ b/apps/st/app/(with-base-layout)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useMemo } from "react"; import { usePathname } from "next/navigation"; import { useQueryClient, useQuery } from "@tanstack/react-query"; @@ -30,10 +30,14 @@ export default function WithBaseLayout({ const queryClient = useQueryClient(); const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); + const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo); const addTab = useTabStore((state) => state.addTab); const setActiveTab = useTabStore((state) => state.setActiveTab); const setMenuList = useTabStore((state) => state.setMenuList); + const activeTabId = useTabStore((state) => state.activeTabId); + const menuList = useTabStore((state) => state.menuList); + const tabs = useTabStore((state) => state.tabs); const addTabForGrid = gridTabStore((state) => state.addTab); const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab); const setMenuListForGrid = gridTabStore((state) => state.setMenuList); @@ -64,6 +68,50 @@ export default function WithBaseLayout({ } }, [pathname, setActiveTab, setActiveTabForGrid]); + // 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거 + const currentPath = useMemo(() => { + if (tabs.length === 0) return ""; + + if (activeTabId && menuList) { + // menuList에서 활성 탭의 메뉴 찾기 + const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => { + for (const item of items) { + if (item.menuEngNm === activeTabId) { + return item; + } + if (item.children) { + const found = findMenuByEngNm(item.children); + if (found) return found; + } + } + return null; + }; + + const activeMenu = findMenuByEngNm(menuList); + + if (activeMenu?.urlPath) { + return activeMenu.urlPath; + } + } + + // 탭에서 직접 찾기 + if (activeTabId) { + const currentTabs = getTabStoreState().tabs; + const activeTab = currentTabs.find((t) => t.id === activeTabId); + if (activeTab?.menuItem?.urlPath) { + return activeTab.menuItem.urlPath; + } + } + + // currentMenuInfo에서 가져오기 + if (currentMenuInfo?.urlPath) { + return currentMenuInfo.urlPath; + } + + // 기본값으로 pathname 사용 + return pathname ?? ""; + }, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]); + const handleMenuClick = useCallback((item: MenuItem) => { setCurrentMenuInfo(item); const addTabResult: boolean = addTab(item); @@ -77,12 +125,12 @@ export default function WithBaseLayout({ alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다. 기존 화면을 닫은 후 이용해주세요.`); } - }, [setCurrentMenuInfo, addTab, addTabForGrid]); + }, [setCurrentMenuInfo, addTab, addTabForGrid, alert]); return ( {children} diff --git a/apps/st/src/components/button/NotApplicableCheckButton.tsx b/apps/st/src/components/button/NotApplicableCheckButton.tsx index f3a6c4225..507334e2e 100644 --- a/apps/st/src/components/button/NotApplicableCheckButton.tsx +++ b/apps/st/src/components/button/NotApplicableCheckButton.tsx @@ -13,7 +13,7 @@ const NotApplicableCheckButton = ({ isCheck, setIsCheck }: NotApplicableCheckBut }; return ( -
+
-1|0|1 */ +interface RealGridDataProviderComparer { + 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 { + return (field, row1, row2) => { + const get = dp.getValue?.bind(dp); + if (!get) return 0; + const val1 = get(row1, field); + const val2 = get(row2, field); + if (val1 == null && val2 == null) return 0; + if (val1 == null) return 1; + if (val2 == null) return -1; + const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1)); + const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2)); + if (isNaN(num1) && isNaN(num2)) return 0; + if (isNaN(num1)) return 1; + if (isNaN(num2)) return -1; + return num1 > num2 ? 1 : num1 < num2 ? -1 : 0; + }; +} + const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -28,54 +53,21 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; - const fieldsToSet: Array<{ fieldName: string; dataType: string }> = []; - const fieldMap = new Map(); - - columns.forEach(col => { - if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) { - fieldMap.set(col.fieldName, col.valueType); - fieldsToSet.push({ - fieldName: col.fieldName, - dataType: col.valueType - }); - } - }); - - // 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트 - if (fieldsToSet.length > 0) { - try { - // 기존 필드 정보 가져오기 - const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || []; - const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text'])); - - // valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트 - const fieldsToUpdate = fieldsToSet.filter(field => { - const existingDataType = existingFieldMap.get(field.fieldName); - return existingDataType !== field.dataType; - }); - - if (fieldsToUpdate.length > 0) { - // 기존 필드 정보를 유지하면서 dataType만 업데이트 - // 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용 - const allFields = existingFields.map(f => ({ - fieldName: f.fieldName, - dataType: fieldMap.get(f.fieldName) || f.dataType || 'text' - })); - - // 새로 추가할 필드가 있으면 추가 - fieldsToSet.forEach(field => { - if (!existingFieldMap.has(field.fieldName)) { - allFields.push({ fieldName: field.fieldName, dataType: field.dataType }); - } - }); - - // setFields를 호출하여 필드의 dataType 업데이트 - (dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void }) - .setFields(allFields); + // setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음) + const dp = dataProvider as RealGridDataProviderComparer; + if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") { + const numberComparer = createNumberComparer(dp); + const seen = new Set(); + columns.forEach((col) => { + if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) { + seen.add(col.fieldName); + try { + dp.setDataComparer!(col.fieldName, numberComparer); + } catch (e) { + console.warn("setDataComparer:", e); + } } - } catch (error) { - console.warn('Failed to set field dataType:', error); - } + }); } gridView.setDisplayOptions({ @@ -92,7 +84,25 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, // footer가 있는 컬럼이 있으면 footer 높이 설정 const hasFooter = columns.some(col => col.footer); if (hasFooter) { - gridView.footer.height = 40; + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + 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; + }); + 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 })); + gridView.setFooters?.(footerHeights); + } else { + gridView.footer.height = 40; + } } // 고정 컬럼 설정 @@ -106,15 +116,112 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, summaryMode: "aggregate" }); - // footer의 valueCallback 설정 - columns.forEach(col => { - if (col.footer && col.footer.valueCallback) { - const column = gridView.columnByName(col.name); - if (column && column.footer) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + // 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 checkFooterUserSpans(item.items); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 + setTimeout(() => { + if (gridRef.current?.gridView && columnLayout) { + gridRef.current.gridView.setColumnLayout?.(columnLayout); + } + }, 100); + } + + // footer 설정 (배열 또는 단일 객체 모두 처리) + // setFooters 호출 후 footer 배열 설정 + setTimeout(() => { + const currentGridView = gridRef.current?.gridView; + if (!currentGridView || !currentGridView.columnByName) { + console.warn('GridView not available for footer setup'); + return; + } + + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 + const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + if (hasArrayFooter) { + // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 + const columnsWithFooter = columns.map(col => { + const colInfo: any = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + }; + + 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; + }); + + try { + (currentGridView as any).setColumns?.(columnsWithFooter); + + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 + setTimeout(() => { + columns.forEach(col => { + if (!col.footer || !Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column) return; + + // footer 배열의 각 요소에 valueCallback 설정 + col.footer.forEach((footerItem, index) => { + if (footerItem.valueCallback && column.footer) { + // footer가 배열인 경우 + if (Array.isArray(column.footer) && column.footer[index]) { + (column.footer[index] as any).valueCallback = footerItem.valueCallback; + } + } + }); + } catch (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); } } - }); + + // footer가 단일 객체이고 valueCallback이 있는 경우 + 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; + } + } catch (e) { + console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + } + }); + }, 200); }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]); @@ -136,7 +243,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} header={column.header} editable={column.editable} - footer={column.footer ? { + footer={column.footer && !Array.isArray(column.footer) ? { text: column.footer.text, expression: column.footer.expression, numberFormat: column.footer.numberFormat, diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 2ca17b5c0..dd14d4d4d 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -19,8 +19,6 @@ declare module "realgrid" { summaryMode?: 'aggregate' | 'statistical' | 'none'; checkBar?: { visible?: boolean }; }): void; - setFooters?(footers: Array<{ height?: number }>): void; - setColumnLayout?(layout: unknown[]): void; // footerUserSpans를 포함한 레이아웃 설정 } } @@ -38,10 +36,245 @@ if (typeof window !== "undefined") { 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; + getValue?(row: number, field: string | number): unknown; +} + +/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ +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; + const val1 = get(row1, field); + const val2 = get(row2, field); + if (val1 == null && val2 == null) return 0; + if (val1 == null) return 1; + if (val2 == null) return -1; + const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1)); + const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2)); + if (isNaN(num1) && isNaN(num2)) return 0; + if (isNaN(num1)) return 1; + if (isNaN(num2)) return -1; + return num1 > num2 ? 1 : num1 < num2 ? -1 : 0; + }; +} + +/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ +function toNum(v: unknown): number { + if (v == null) return NaN; + if (typeof v === "number") return isNaN(v) ? NaN : v; + return parseFloat(String(v).replace(/,/g, "")) || NaN; +} + +/** 헤더에 정렬 화살표 표시 (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; + }, + columns: { fieldName?: string; name: string }[], + 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); + if (!c) break; + if (c.fieldName === (col.fieldName || col.name) || c.name === (col.fieldName || col.name)) { + gridCol = c; + break; + } + } + if (!gridCol) { + gridCol = gridView.columnByName?.(col.name); + } + + if (!gridCol) return; + + 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 gridEl = gridEl1 || gridEl2; + if (!gridEl) return; + + // 헤더 셀 찾기 (fieldName 또는 name으로) + 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)))) { + targetCell = el; + } + }); + + if (!targetCell) return; + + const headerCell = targetCell as HTMLElement; + + // 기존 화살표 제거 + const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null; + if (existingIndicator) { + existingIndicator.remove(); + } + + // 화살표 추가 + if (direction === "asc" || direction === "desc") { + const indicator = document.createElement("span"); + indicator.className = "rg-sort-indicator"; + indicator.textContent = direction === "asc" ? "▲" : "▼"; + indicator.style.marginLeft = "4px"; + indicator.style.fontSize = "10px"; + indicator.style.color = "#666"; + indicator.style.display = "inline-block"; + indicator.style.verticalAlign = "middle"; + headerCell.appendChild(indicator); + } + }); + } catch (e) { + console.warn("updateHeaderSortIndicator error:", e); + } +} + +/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */ +function createOnSortingHandler( + columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[], + setRowData: React.Dispatch[]>>, + sortDirRef: React.MutableRefObject>, + originalRowsRef: React.MutableRefObject[]>> +) { + 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 groupField = mergeCellCol?.fieldName || null; + const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음 + 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 } }; + 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; + } + if (!sortField) return true; + 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; + rows.push(r); + } + const prev = sortDirRef.current[sortField] ?? "none"; + const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds); + + if (prev === "desc") { + const orig = originalRowsRef.current[sortField]; + if (orig && setRows) { + setRows(orig); + setRowData(orig); + } + sortDirRef.current[sortField] = "none"; + return false; + } + if (prev === "none") { + originalRowsRef.current[sortField] = rows.map((r) => ({ ...r })); + } + const sortAsc = prev !== "asc"; + sortDirRef.current[sortField] = sortAsc ? "asc" : "desc"; + const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType); + setRows?.(sorted); + setRowData(sorted); + return false; + }; +} + +/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */ +function sortRowsGroupPreserving( + rows: Record[], + sortField: string, + sortAsc: boolean, + opt: { groupField: string; orderByField?: string }, + valueType?: ValueType +): Record[] { + const groups = new Map[]>(); + for (const r of rows) { + const k = String(r[opt.groupField] ?? ""); + if (!groups.has(k)) groups.set(k, []); + groups.get(k)!.push(r); + } + + // valueType에 따라 비교 함수 선택 + const compareValue = (val: unknown): number | string => { + if (valueType === ValueType.NUMBER) { + return toNum(val); + } + // 텍스트나 기타 타입은 문자열로 비교 + 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)); + 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"); + if (strs.length === 0) return sortAsc ? "\uffff" : ""; + strs.sort(); + 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]); + if (typeof valA === "number" && typeof valB === "number") { + return (sortAsc ? 1 : -1) * (valA - valB); + } + // 문자열 비교 + const strA = String(valA); + const strB = String(valB); + return (sortAsc ? 1 : -1) * strA.localeCompare(strB); + }); + + return entries.flatMap(([, arr]) => arr); +} + const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); const fileInputRef = useRef(null); + const sortDirRef = useRef>({}); + const originalRowsRef = useRef[]>>({}); const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); const menuList = useTabStore((state) => state.menuList); @@ -211,71 +444,58 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; - const fieldsToSet: Array<{ fieldName: string; dataType: string }> = []; - const fieldMap = new Map(); + // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 + const hasMergeCells = columns.some((col) => col.mergeCells === true); - columns.forEach(col => { - if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) { - fieldMap.set(col.fieldName, col.valueType); - fieldsToSet.push({ - fieldName: col.fieldName, - dataType: col.valueType - }); - } - }); - - // 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트 - if (fieldsToSet.length > 0) { - try { - // 기존 필드 정보 가져오기 - const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || []; - const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text'])); - - // valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트 - const fieldsToUpdate = fieldsToSet.filter(field => { - const existingDataType = existingFieldMap.get(field.fieldName); - return existingDataType !== field.dataType; - }); - - if (fieldsToUpdate.length > 0) { - // 기존 필드 정보를 유지하면서 dataType만 업데이트 - // 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용 - const allFields = existingFields.map(f => ({ - fieldName: f.fieldName, - dataType: fieldMap.get(f.fieldName) || f.dataType || 'text' - })); - - // 새로 추가할 필드가 있으면 추가 - fieldsToSet.forEach(field => { - if (!existingFieldMap.has(field.fieldName)) { - allFields.push({ fieldName: field.fieldName, dataType: field.dataType }); - } - }); - - // setFields를 호출하여 필드의 dataType 업데이트 - (dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void }) - .setFields(allFields); + // setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리) + const dp = dataProvider as RealGridDataProviderComparer; + 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)) { + seen.add(col.fieldName); + try { + dp.setDataComparer!(col.fieldName, numberComparer); + } catch (e) { + console.warn("setDataComparer:", e); + } } - } catch (error) { - console.warn('Failed to set field dataType:', error); - } + }); } + // mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용 + (gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + (gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN; gridView.header.height = 60; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ checkBar: { visible: showHeaderCheckbox }, + rowIndicator: { visible: false }, }); // footer가 있는 컬럼이 있으면 footer 높이 설정 const hasFooter = columns.some(col => col.footer); if (hasFooter) { - // multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) - const hasMultiRowFooter = columns.some(col => col.footer && col.footer.multiRow); - if (hasMultiRowFooter) { - gridView.setFooters?.([{height: 40}, {height: 40}]); + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + 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; + }); + 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 })); + gridView.setFooters?.(footerHeights); } else { gridView.footer.height = 40; } @@ -304,7 +524,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }); }; - if (checkFooterUserSpans(columnLayout) && gridView.setColumnLayout) { + if (checkFooterUserSpans(columnLayout)) { // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { if (gridRef.current?.gridView && columnLayout) { @@ -313,18 +533,95 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }, 100); } - // footer의 valueCallback 설정 - columns.forEach(col => { - if (col.footer && col.footer.valueCallback) { - const column = gridView.columnByName(col.name); - if (column && column.footer) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + // footer 설정 (배열 또는 단일 객체 모두 처리) + // setFooters 호출 후 footer 배열 설정 + setTimeout(() => { + const currentGridView = gridRef.current?.gridView; + if (!currentGridView || !currentGridView.columnByName) { + console.warn('GridView not available for footer setup'); + return; + } + + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 + const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + if (hasArrayFooter) { + // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 + const columnsWithFooter = columns.map(col => { + const colInfo: any = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + }; + + 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; + }); + + try { + (currentGridView as any).setColumns?.(columnsWithFooter); + + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 + setTimeout(() => { + columns.forEach(col => { + if (!col.footer || !Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column) return; + + // footer 배열의 각 요소에 valueCallback 설정 + col.footer.forEach((footerItem, index) => { + if (footerItem.valueCallback && column.footer) { + // footer가 배열인 경우 + if (Array.isArray(column.footer) && column.footer[index]) { + (column.footer[index] as any).valueCallback = footerItem.valueCallback; + } + } + }); + } catch (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); } } - }); + + // footer가 단일 객체이고 valueCallback이 있는 경우 + 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; + } + } catch (e) { + console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + } + }); + }, 200); }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]); + return (
{ + (grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + }} > {columns.map((column) => ( [], options?: { fillMode?: string }): void; +} + +/** 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; + getValue?(row: number, field: string | number): unknown; +} + // Window 인터페이스 확장 declare global { interface Window { @@ -38,10 +48,252 @@ if (typeof window !== "undefined") { RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ''); +/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ +function toNum(v: unknown): number { + if (v == null) return NaN; + if (typeof v === "number") return isNaN(v) ? NaN : v; + return parseFloat(String(v).replace(/,/g, "")) || NaN; +} + +/** 헤더에 정렬 화살표 표시 (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; + getElement?: () => HTMLElement | null; + container?: HTMLElement; + }, + columns: { fieldName?: string; name: string }[], + sortDir: Record, + gridElement?: HTMLElement | null +) { + try { + // 그리드 DOM 요소 찾기 + let gridEl: HTMLElement | null = null; + if (gridElement) { + gridEl = gridElement; + } else if (gridView.getElement) { + gridEl = gridView.getElement(); + } else if (gridView.container) { + gridEl = gridView.container; + } else { + // 폴백: 모든 그리드 중에서 찾기 + const allGrids = document.querySelectorAll('.rg-grid, [data-grid-id]'); + if (allGrids.length > 0) { + // 활성 탭의 그리드를 찾기 위해 TabsContent 내부의 그리드 찾기 + const activeTabContent = document.querySelector('[data-state="active"]')?.closest('[role="tabpanel"]'); + if (activeTabContent) { + gridEl = activeTabContent.querySelector('.rg-grid, [data-grid-id]') as HTMLElement; + } + if (!gridEl && allGrids.length === 1) { + gridEl = allGrids[0] as HTMLElement; + } + } + } + + if (!gridEl) return; + + columns.forEach((col) => { + if (!col.fieldName && !col.name) return; + + const fieldName = col.fieldName || col.name; + const direction = sortDir[fieldName] || sortDir[col.name] || "none"; + if (direction === "none") return; + + // 헤더 셀 찾기 (fieldName 또는 name으로) + const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"], .rg-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 === colName || + text === colFieldName || + 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; + if (existingIndicator) { + existingIndicator.remove(); + } + + // 화살표 추가 + const indicator = document.createElement("span"); + indicator.className = "rg-sort-indicator"; + indicator.textContent = direction === "asc" ? "▲" : "▼"; + indicator.style.marginLeft = "4px"; + indicator.style.fontSize = "10px"; + indicator.style.color = "#666"; + indicator.style.display = "inline-block"; + indicator.style.verticalAlign = "middle"; + headerCell.appendChild(indicator); + }); + } catch (e) { + console.warn("updateHeaderSortIndicator error:", e); + } +} + +/** 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[]>>, + 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 groupField = mergeCellCol?.fieldName || null; + const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음 + 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 } }; + 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; + } + if (!sortField) return true; + 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; + rows.push(r); + } + const prev = sortDirRef.current[sortField] ?? "none"; + const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds); + + if (prev === "desc") { + const orig = originalRowsRef.current[sortField]; + if (orig && setRows) { + setRows(orig); + setRowData(orig); + } + sortDirRef.current[sortField] = "none"; + return false; + } + if (prev === "none") { + originalRowsRef.current[sortField] = rows.map((r) => ({ ...r })); + } + const sortAsc = prev !== "asc"; + sortDirRef.current[sortField] = sortAsc ? "asc" : "desc"; + const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType); + setRows?.(sorted); + setRowData(sorted); + return false; + }; +} + +/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */ +function sortRowsGroupPreserving( + rows: Record[], + sortField: string, + sortAsc: boolean, + opt: { groupField: string; orderByField?: string }, + valueType?: ValueType +): Record[] { + const groups = new Map[]>(); + for (const r of rows) { + const k = String(r[opt.groupField] ?? ""); + if (!groups.has(k)) groups.set(k, []); + groups.get(k)!.push(r); + } + + // valueType에 따라 비교 함수 선택 + const compareValue = (val: unknown): number | string => { + if (valueType === ValueType.NUMBER) { + return toNum(val); + } + // 텍스트나 기타 타입은 문자열로 비교 + 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)); + 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"); + if (strs.length === 0) return sortAsc ? "\uffff" : ""; + strs.sort(); + 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]); + if (typeof valA === "number" && typeof valB === "number") { + return (sortAsc ? 1 : -1) * (valA - valB); + } + // 문자열 비교 + const strA = String(valA); + const strB = String(valB); + return (sortAsc ? 1 : -1) * strA.localeCompare(strB); + }); + + return entries.flatMap(([, arr]) => arr); +} + +/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ +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; + const val1 = get(row1, field); + const val2 = get(row2, field); + if (val1 == null && val2 == null) return 0; + if (val1 == null) return 1; + if (val2 == null) return -1; + const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1)); + const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2)); + if (isNaN(num1) && isNaN(num2)) return 0; + if (isNaN(num1)) return 1; + if (isNaN(num2)) return -1; + return num1 > num2 ? 1 : num1 < num2 ? -1 : 0; + }; +} + const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => { // 각 탭별로 그리드 ref와 데이터를 관리 const gridRefs = useRef>(new Map()); const fileInputRefs = useRef>(new Map()); + // 각 탭별로 정렬 상태 관리 + const sortDirRefs = useRef>>>(new Map()); + const originalRowsRefs = useRef[]>>>>(new Map()); const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); const menuList = useTabStore((state) => state.menuList); @@ -244,6 +496,30 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r } }, [rows, columnInfo]); + // rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등) + useEffect(() => { + columnInfo.forEach((info) => { + const tabKey = info.tabKey; + const gridRef = gridRefs.current.get(tabKey); + const currentData = rowDataMap.get(tabKey); + + if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) { + setTimeout(() => { + try { + const dp = gridRef.dataProvider as RealGridDataProviderFill | null; + if (dp?.fillJsonData) { + dp.fillJsonData(currentData, { fillMode: "set" }); + } + gridRef.gridView.refresh(); + } catch (error) { + console.error("그리드 데이터 설정 오류:", error); + } + }, 100); + } + }); + }, [rowDataMap, columnInfo]); + + // 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때) useEffect(() => { const gridRef = gridRefs.current.get(activeTab); @@ -378,7 +654,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }
-
+
{ if (el) { @@ -391,6 +667,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const currentInfo = columnInfo.find(info => info.tabKey === tabKey); if (!currentInfo || !currentInfo.columns) return; + + const columns = currentInfo.columns; gridView.setDisplayOptions({ fitStyle: GridFitStyle.EVEN @@ -404,24 +682,140 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }); // footer가 있는 컬럼이 있으면 footer 높이 설정 - const hasFooter = currentInfo.columns.some(col => col.footer); + const hasFooter = columns.some(col => col.footer); if (hasFooter) { - gridView.footer.height = 40; + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + 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; + }); + 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 })); + gridView.setFooters?.(footerHeights); + } else { + gridView.footer.height = 40; + } } gridView.setOptions({ summaryMode: "aggregate" }); - // footer의 valueCallback 설정 - currentInfo.columns.forEach(col => { - if (col.footer && col.footer.valueCallback) { - const column = gridView.columnByName(col.name); - if (column && column.footer) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + // 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 checkFooterUserSpans(item.items); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 + setTimeout(() => { + if (gridView && columnLayout) { + gridView.setColumnLayout?.(columnLayout); + } + }, 100); + } + + // footer 설정 (배열 또는 단일 객체 모두 처리) + // setFooters 호출 후 footer 배열 설정 + setTimeout(() => { + const currentGridView = gridView; + if (!currentGridView || !currentGridView.columnByName) { + console.warn('GridView not available for footer setup'); + return; + } + + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 + const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + if (hasArrayFooter) { + // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 + const columnsWithFooter = columns.map(col => { + const colInfo: any = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + }; + + 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; + }); + + try { + (currentGridView as any).setColumns?.(columnsWithFooter); + + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 + setTimeout(() => { + columns.forEach(col => { + if (!col.footer || !Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column) return; + + // footer 배열의 각 요소에 valueCallback 설정 + col.footer.forEach((footerItem, index) => { + if (footerItem.valueCallback && column.footer) { + // footer가 배열인 경우 + if (Array.isArray(column.footer) && column.footer[index]) { + (column.footer[index] as any).valueCallback = footerItem.valueCallback; + } + } + }); + } catch (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); } } - }); + + // footer가 단일 객체이고 valueCallback이 있는 경우 + columns.forEach(col => { + if (!col.footer || Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column || !column.footer) return; + + const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown }; + if (footerObj.valueCallback) { + (column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback; + } + } catch (e) { + console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + } + }); + }, 200); // 고정 컬럼 설정 if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) { @@ -430,60 +824,60 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }); } - // valueType이 설정된 컬럼에 대해 필드의 dataType을 명시적으로 설정 - // autoGenerateField={true}일 때 valueType prop만으로는 필드의 dataType이 설정되지 않을 수 있음 + // 각 탭별 ref 초기화 + if (!sortDirRefs.current.has(tabKey)) { + sortDirRefs.current.set(tabKey, { current: {} }); + } + if (!originalRowsRefs.current.has(tabKey)) { + originalRowsRefs.current.set(tabKey, { current: {} }); + } + + const sortDirRef = sortDirRefs.current.get(tabKey)!; + const originalRowsRef = originalRowsRefs.current.get(tabKey)!; + + // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 + const hasMergeCells = columns.some((col) => col.mergeCells === true); + + // setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리) if (el.dataProvider) { - const dataProvider = el.dataProvider; - const fieldsToSet: Array<{ fieldName: string; dataType: string }> = []; - const fieldMap = new Map(); - - currentInfo.columns.forEach(col => { - if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) { - fieldMap.set(col.fieldName, col.valueType); - fieldsToSet.push({ - fieldName: col.fieldName, - dataType: col.valueType - }); - } - }); - - // 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트 - if (fieldsToSet.length > 0) { - try { - // 기존 필드 정보 가져오기 - const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || []; - const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text'])); - - // valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트 - const fieldsToUpdate = fieldsToSet.filter(field => { - const existingDataType = existingFieldMap.get(field.fieldName); - return existingDataType !== field.dataType; - }); - - if (fieldsToUpdate.length > 0) { - // 기존 필드 정보를 유지하면서 dataType만 업데이트 - // 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용 - const allFields = existingFields.map(f => ({ - fieldName: f.fieldName, - dataType: fieldMap.get(f.fieldName) || f.dataType || 'text' - })); - - // 새로 추가할 필드가 있으면 추가 - fieldsToSet.forEach(field => { - if (!existingFieldMap.has(field.fieldName)) { - allFields.push({ fieldName: field.fieldName, dataType: field.dataType }); - } - }); - - // setFields를 호출하여 필드의 dataType 업데이트 - (dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void }) - .setFields(allFields); + const dp = el.dataProvider as RealGridDataProviderComparer; + 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)) { + seen.add(col.fieldName); + try { + dp.setDataComparer!(col.fieldName, numberComparer); + } catch (e) { + console.warn("setDataComparer:", e); + } } - } catch (error) { - console.warn('Failed to set field dataType:', error); - } + }); } } + + // mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용 + const setRowData = (updater: React.SetStateAction[]>) => { + setRowDataMap(prev => { + const newMap = new Map(prev); + const currentData = newMap.get(tabKey) || []; + const newData = typeof updater === 'function' ? updater(currentData) : updater; + newMap.set(tabKey, newData); + return newMap; + }); + }; + + // 그리드 DOM 요소 찾기 (나중에 화살표 업데이트에 사용) + // 탭별로 그리드 요소를 찾기 위해 data-tab-key 사용 + const findGridElement = () => { + const tabContent = document.querySelector(`[data-tab-key="${tabKey}"]`); + return tabContent?.querySelector('.rg-grid, [data-grid-id]') as HTMLElement | null; + }; + + const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement); + (gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = onSortingHandler; }, 0); } } else { @@ -503,10 +897,10 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })} header={column.header} editable={column.editable} - footer={column.footer ? { - text: column.footer.text, - expression: column.footer.expression, - numberFormat: column.footer.numberFormat, + footer={column.footer && !Array.isArray(column.footer) ? { + text: (column.footer as { text?: string }).text, + expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression, + numberFormat: (column.footer as { numberFormat?: string }).numberFormat, } : undefined} mergeRule={column.mergeCells ? { criteria: 'value' } : undefined} sortable={!column.sortdisable} diff --git a/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx b/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx index c50bd2eb9..94fbb6a3e 100644 --- a/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { DataGrid } from "@rsone/components"; import SubmitButton from "@/components/button/SumitButton"; @@ -49,7 +50,7 @@ const ExcretaTreatmentFacilityInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx index b6f30faf2..0dabb52c8 100644 --- a/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from "@rsone/components"; import SubmitButton from "@/components/button/SumitButton"; @@ -40,7 +41,7 @@ const PrivateSewageFacilityInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index 792ed487e..0a8e1d277 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; + import SearchForm from '@/components/module/SearchForm'; import { DataGrid } from '@rsone/components'; import SubmitButton from '@/components/button/SumitButton'; @@ -43,7 +44,7 @@ const PublicSewageFacilityInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/PumpingStationInp.tsx b/apps/st/src/components/statistics/input/PumpingStationInp.tsx index efc962b02..2261a8eac 100644 --- a/apps/st/src/components/statistics/input/PumpingStationInp.tsx +++ b/apps/st/src/components/statistics/input/PumpingStationInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { DataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -49,7 +50,7 @@ const PumpingStationInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewageExcretaRelatedInp.tsx b/apps/st/src/components/statistics/input/SewageExcretaRelatedInp.tsx index bb2d3693d..726d5718a 100644 --- a/apps/st/src/components/statistics/input/SewageExcretaRelatedInp.tsx +++ b/apps/st/src/components/statistics/input/SewageExcretaRelatedInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from "@rsone/components"; import SubmitButton from "@/components/button/SumitButton"; @@ -53,7 +54,7 @@ const SewageExcretaRelatedInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewageExcretaSlurgeInp.tsx b/apps/st/src/components/statistics/input/SewageExcretaSlurgeInp.tsx index 339ebbb8b..a4ab5c947 100644 --- a/apps/st/src/components/statistics/input/SewageExcretaSlurgeInp.tsx +++ b/apps/st/src/components/statistics/input/SewageExcretaSlurgeInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from "@rsone/components"; import SubmitButton from "@/components/button/SumitButton"; @@ -55,7 +56,7 @@ const SewageExcretaSlurgeInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx index 2b54d8cc0..4eac16126 100644 --- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -50,7 +51,7 @@ const SewagePipeInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx b/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx index 222f90f52..c34d35498 100644 --- a/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -42,7 +43,7 @@ const SewagePipeMaintainInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx b/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx index c59f35d95..1ea0892c5 100644 --- a/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx +++ b/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -56,7 +57,7 @@ const SewageReservoirPumpInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx b/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx index 7e70c7611..c1c2177de 100644 --- a/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx +++ b/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -42,7 +43,7 @@ const SewerageFinanceInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 47e122751..1f3c75eb7 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { DataGrid } from "@rsone/components"; import SubmitButton from "@/components/button/SumitButton"; @@ -5,8 +7,6 @@ import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; -import { useState } from "react"; - import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn"; // 하수도 통계 관리 > 01. 하수도 보급률 @@ -32,7 +32,7 @@ const SeweragePenetrationRateInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx b/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx index 9e9beb631..7ce370bbb 100644 --- a/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx +++ b/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -40,7 +41,7 @@ const SewerageServiceLevelInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index 567ff1cea..46f8620c2 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -1,8 +1,9 @@ +import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { DataGrid } from "@rsone/components"; import ApproveButton from "@/components/button/ApproveButton"; import RejectButton from "@/components/button/RejectButton"; -import { useState } from "react"; import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn"; @@ -21,7 +22,7 @@ const StatsInputStatusInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/input/WaterReuseInp.tsx b/apps/st/src/components/statistics/input/WaterReuseInp.tsx index 5cf842ccc..2479ef030 100644 --- a/apps/st/src/components/statistics/input/WaterReuseInp.tsx +++ b/apps/st/src/components/statistics/input/WaterReuseInp.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import SearchForm from "@/components/module/SearchForm"; import { TabDataGrid } from '@rsone/components'; import SubmitButton from "@/components/button/SumitButton"; @@ -62,7 +63,7 @@ const WaterReuseInp = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/management/NotEnteredStatus.tsx b/apps/st/src/components/statistics/management/NotEnteredStatus.tsx index 8dd2c1990..723c29b18 100644 --- a/apps/st/src/components/statistics/management/NotEnteredStatus.tsx +++ b/apps/st/src/components/statistics/management/NotEnteredStatus.tsx @@ -32,7 +32,7 @@ const NotEnteredStatus = () => { return ( <> - + ); diff --git a/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx b/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx index 7df0d37f4..be4158ede 100644 --- a/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsInputManagement.tsx @@ -30,7 +30,7 @@ const StatisticsInputManagement = () => { return ( <> - + ); diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index 9632bf66c..3d979e9d6 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const StatisticsPopulationManagement = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/management/StatisticsReviewHistory.tsx b/apps/st/src/components/statistics/management/StatisticsReviewHistory.tsx index 314828e36..96bde4c32 100644 --- a/apps/st/src/components/statistics/management/StatisticsReviewHistory.tsx +++ b/apps/st/src/components/statistics/management/StatisticsReviewHistory.tsx @@ -30,7 +30,7 @@ const StatisticsReviewHistory = () => { return ( <> - + ); diff --git a/apps/st/src/components/statistics/review/ExcretaTreatmentFacilityRvw.tsx b/apps/st/src/components/statistics/review/ExcretaTreatmentFacilityRvw.tsx index a084dc78e..961f0a79e 100644 --- a/apps/st/src/components/statistics/review/ExcretaTreatmentFacilityRvw.tsx +++ b/apps/st/src/components/statistics/review/ExcretaTreatmentFacilityRvw.tsx @@ -21,7 +21,7 @@ const ExcretaTreatmentFacilityRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/PrivateSewageFacilityRvw.tsx b/apps/st/src/components/statistics/review/PrivateSewageFacilityRvw.tsx index 8d2ab711e..619085736 100644 --- a/apps/st/src/components/statistics/review/PrivateSewageFacilityRvw.tsx +++ b/apps/st/src/components/statistics/review/PrivateSewageFacilityRvw.tsx @@ -35,7 +35,7 @@ const PrivateSewageFacilityRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/PublicSewageFacilityRvw.tsx b/apps/st/src/components/statistics/review/PublicSewageFacilityRvw.tsx index 4a47b7d2c..b929e1e3f 100644 --- a/apps/st/src/components/statistics/review/PublicSewageFacilityRvw.tsx +++ b/apps/st/src/components/statistics/review/PublicSewageFacilityRvw.tsx @@ -31,7 +31,7 @@ const PublicSewageFacilityRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/PumpingStationRvw.tsx b/apps/st/src/components/statistics/review/PumpingStationRvw.tsx index c4bb86c39..8a9df541a 100644 --- a/apps/st/src/components/statistics/review/PumpingStationRvw.tsx +++ b/apps/st/src/components/statistics/review/PumpingStationRvw.tsx @@ -20,7 +20,7 @@ const PumpingStationRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SewageExcretaRelatedRvw.tsx b/apps/st/src/components/statistics/review/SewageExcretaRelatedRvw.tsx index 70e65cb87..bd594d334 100644 --- a/apps/st/src/components/statistics/review/SewageExcretaRelatedRvw.tsx +++ b/apps/st/src/components/statistics/review/SewageExcretaRelatedRvw.tsx @@ -35,7 +35,7 @@ const SewageExcretaRelatedRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SewageExcretaSlurgeRvw.tsx b/apps/st/src/components/statistics/review/SewageExcretaSlurgeRvw.tsx index ff70a184c..d0dbcb383 100644 --- a/apps/st/src/components/statistics/review/SewageExcretaSlurgeRvw.tsx +++ b/apps/st/src/components/statistics/review/SewageExcretaSlurgeRvw.tsx @@ -35,7 +35,7 @@ const SewageExcretaSlurgeRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SewagePipeMaintainRvw.tsx b/apps/st/src/components/statistics/review/SewagePipeMaintainRvw.tsx index 634015c00..03549083e 100644 --- a/apps/st/src/components/statistics/review/SewagePipeMaintainRvw.tsx +++ b/apps/st/src/components/statistics/review/SewagePipeMaintainRvw.tsx @@ -9,7 +9,6 @@ import ReturnButton from "@/components/button/ReturnButton"; import { SewagePipeMaintainColumnInfoList } from "@/components/util/column/review/SewagePipeMaintainRvwColumn"; - // 하수도 통계 관리 03. 하수관로 유지 관리 const SewagePipeMaintainRvw = () => { const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); @@ -36,7 +35,7 @@ const SewagePipeMaintainRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SewagePipeRvw.tsx b/apps/st/src/components/statistics/review/SewagePipeRvw.tsx index a01b87228..b0069e506 100644 --- a/apps/st/src/components/statistics/review/SewagePipeRvw.tsx +++ b/apps/st/src/components/statistics/review/SewagePipeRvw.tsx @@ -35,7 +35,7 @@ const SewagePipeRvw = () => { return ( <> - +
@@ -44,7 +44,7 @@ const SewagePipeRvw = () => {
- + ); }; diff --git a/apps/st/src/components/statistics/review/SewageReservoirPumpRvw.tsx b/apps/st/src/components/statistics/review/SewageReservoirPumpRvw.tsx index eb76cfaa0..a2b5e8de7 100644 --- a/apps/st/src/components/statistics/review/SewageReservoirPumpRvw.tsx +++ b/apps/st/src/components/statistics/review/SewageReservoirPumpRvw.tsx @@ -35,7 +35,7 @@ const SewageReservoirPumpRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SewerageFinanceRvw.tsx b/apps/st/src/components/statistics/review/SewerageFinanceRvw.tsx index af48997a7..68a5b6cc0 100644 --- a/apps/st/src/components/statistics/review/SewerageFinanceRvw.tsx +++ b/apps/st/src/components/statistics/review/SewerageFinanceRvw.tsx @@ -36,7 +36,7 @@ const SewerageFinanceRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx b/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx index a9d9e2aa7..838298bd8 100644 --- a/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx +++ b/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; -// import { DataGrid } from "@rsone/components"; -import DataGrid from "@/components/module/DataGrid"; +import { DataGrid } from "@rsone/components"; import SearchForm from "@/components/module/SearchForm"; import SaveButton from "@/components/button/SaveButton"; import ApproveButton from "@/components/button/ApproveButton"; @@ -36,7 +35,7 @@ const SeweragePenetrationRateRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/SewerageServiceLevelRvw.tsx b/apps/st/src/components/statistics/review/SewerageServiceLevelRvw.tsx index a016a2a7b..e8aa771df 100644 --- a/apps/st/src/components/statistics/review/SewerageServiceLevelRvw.tsx +++ b/apps/st/src/components/statistics/review/SewerageServiceLevelRvw.tsx @@ -35,7 +35,7 @@ const SewerageServiceLevelRvw = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx b/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx index 501507ff4..c39734749 100644 --- a/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx +++ b/apps/st/src/components/statistics/review/StatsInputStatusRvw.tsx @@ -31,8 +31,8 @@ const StatsInputStatusRvw = () => { return ( <> - -
+ +
@@ -40,13 +40,13 @@ const StatsInputStatusRvw = () => {
- + ); } diff --git a/apps/st/src/components/statistics/review/WaterReuseRvw.tsx b/apps/st/src/components/statistics/review/WaterReuseRvw.tsx index f686570c5..e642a4448 100644 --- a/apps/st/src/components/statistics/review/WaterReuseRvw.tsx +++ b/apps/st/src/components/statistics/review/WaterReuseRvw.tsx @@ -34,15 +34,15 @@ const WaterReuseRvw = () => { return ( <> - -
-
- - - - + +
+
+ + + + +
-
); diff --git a/apps/st/src/components/statistics/total/ExcretaTreatmentFacilityTot.tsx b/apps/st/src/components/statistics/total/ExcretaTreatmentFacilityTot.tsx index cae2b07e6..e470b07fa 100644 --- a/apps/st/src/components/statistics/total/ExcretaTreatmentFacilityTot.tsx +++ b/apps/st/src/components/statistics/total/ExcretaTreatmentFacilityTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const ExcretaTreatmentFacilityTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/PrivateSewageFacilityTot.tsx b/apps/st/src/components/statistics/total/PrivateSewageFacilityTot.tsx index 6ce297104..1171f7b2c 100644 --- a/apps/st/src/components/statistics/total/PrivateSewageFacilityTot.tsx +++ b/apps/st/src/components/statistics/total/PrivateSewageFacilityTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const PrivateSewageFacilityTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/PublicSewageFacilityTot.tsx b/apps/st/src/components/statistics/total/PublicSewageFacilityTot.tsx index 34b1e70ed..6eb3f2991 100644 --- a/apps/st/src/components/statistics/total/PublicSewageFacilityTot.tsx +++ b/apps/st/src/components/statistics/total/PublicSewageFacilityTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const PublicSewageFacilityTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/PumpingStationTot.tsx b/apps/st/src/components/statistics/total/PumpingStationTot.tsx index 620388576..fd4cc679d 100644 --- a/apps/st/src/components/statistics/total/PumpingStationTot.tsx +++ b/apps/st/src/components/statistics/total/PumpingStationTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const PumpingStationTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SewageExcretaRelatedTot.tsx b/apps/st/src/components/statistics/total/SewageExcretaRelatedTot.tsx index 772e0d7fd..adcc810ad 100644 --- a/apps/st/src/components/statistics/total/SewageExcretaRelatedTot.tsx +++ b/apps/st/src/components/statistics/total/SewageExcretaRelatedTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const SewageExcretaRelatedTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SewageExcretaSlurgeTot.tsx b/apps/st/src/components/statistics/total/SewageExcretaSlurgeTot.tsx index 0abb6bae2..c384f1461 100644 --- a/apps/st/src/components/statistics/total/SewageExcretaSlurgeTot.tsx +++ b/apps/st/src/components/statistics/total/SewageExcretaSlurgeTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const SewageExcretaSlurgeTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx b/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx index 8d9c4e3ea..12e71151d 100644 --- a/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx +++ b/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const SewagePipeMaintainTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SewagePipeTot.tsx b/apps/st/src/components/statistics/total/SewagePipeTot.tsx index 400eea75e..232ef7883 100644 --- a/apps/st/src/components/statistics/total/SewagePipeTot.tsx +++ b/apps/st/src/components/statistics/total/SewagePipeTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const SewagePipeTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SewageReservoirPumpTot.tsx b/apps/st/src/components/statistics/total/SewageReservoirPumpTot.tsx index 5607b1061..6016a7930 100644 --- a/apps/st/src/components/statistics/total/SewageReservoirPumpTot.tsx +++ b/apps/st/src/components/statistics/total/SewageReservoirPumpTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const SewageReservoirPumpTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SewerageFinanceTot.tsx b/apps/st/src/components/statistics/total/SewerageFinanceTot.tsx index ca5f21730..a73eaba2f 100644 --- a/apps/st/src/components/statistics/total/SewerageFinanceTot.tsx +++ b/apps/st/src/components/statistics/total/SewerageFinanceTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const SewerageFinanceTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx b/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx index 3f6371c30..fe266e743 100644 --- a/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx +++ b/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx @@ -127,7 +127,7 @@ const SeweragePenetrationRateTot = () => { return ( <> - +
diff --git a/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx b/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx index b926e113b..2f6b5131e 100644 --- a/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx +++ b/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "../../module/SearchForm"; const SewerageServiceLevelTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/total/WaterReuseTot.tsx b/apps/st/src/components/statistics/total/WaterReuseTot.tsx index b0ee88c7c..567797983 100644 --- a/apps/st/src/components/statistics/total/WaterReuseTot.tsx +++ b/apps/st/src/components/statistics/total/WaterReuseTot.tsx @@ -3,7 +3,7 @@ import SearchForm from "../../module/SearchForm"; const WaterReuseTot = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/statistics/verification/ManagementVerificationRule.tsx b/apps/st/src/components/statistics/verification/ManagementVerificationRule.tsx index 040a68b51..6e9cc3a6e 100644 --- a/apps/st/src/components/statistics/verification/ManagementVerificationRule.tsx +++ b/apps/st/src/components/statistics/verification/ManagementVerificationRule.tsx @@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm"; const ManagementVerificationRule = () => { return ( <> - + ); }; diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index 7539c6642..9f9e16a70 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -25,6 +25,12 @@ export type ColumnFooter = { numberFormat?: string; }; +/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */ +export type SortPreserveGroupOrder = { + groupField: string; + orderByField?: string; +}; + export type ColumnConfig = { name: string; fieldName: string; @@ -32,6 +38,7 @@ export type ColumnConfig = { editable?: boolean; // 셀 편집 가능 여부 mergeCells?: boolean; sortdisable?: boolean; + sortPreserveGroupOrder?: SortPreserveGroupOrder; valueType?: ValueType; dataFormat?: string; footer?: { @@ -40,7 +47,12 @@ export type ColumnConfig = { numberFormat?: string; valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string; multiRow?: boolean; // 여러 행 footer 사용 여부 - }, + } | Array<{ + text?: string; + expression?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + numberFormat?: string; + valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string; + }>, // RealGrid에서 footer를 배열로 설정할 수 있음 }; export type FooterUserSpan = { diff --git a/apps/st/src/components/util/column/review/SewagePipeRvwColumn.ts b/apps/st/src/components/util/column/review/SewagePipeRvwColumn.ts index 6c11567f3..c6b366139 100644 --- a/apps/st/src/components/util/column/review/SewagePipeRvwColumn.ts +++ b/apps/st/src/components/util/column/review/SewagePipeRvwColumn.ts @@ -6,13 +6,13 @@ const rawList = [ { tabKey: 'a', tabLabel: '지역별 시설현황', - rowStartNum: 2, - fixedColumns: 1, + rowStartNum: 4, + fixedColumns: 2, columns: [ /* 기본 정보 */ - { name: 'SIDO', fieldName: '시도', header: { text: '시도' } }, - { name: 'GUGUN', fieldName: '시군구', header: { text: '시군구' } }, - + { name: 'SIDO', fieldName: '시도', header: { text: '시도' }, mergeCells: true }, + { name: 'GUGUN', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true }, + { name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true }, /* 하수정비 기본계획 */ { name: 'TARGET_YR', fieldName: '하수정보기본계획목표년도', header: { text: '하수정보기본계획목표년도' } }, { name: 'PIPL_IMPRMN_PLAN_EXTN', fieldName: '총관로정비 계획연장(m)', header: { text: '총관로정비\n계획연장(m)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.00' }, @@ -64,6 +64,7 @@ const rawList = [ ], columnLayout: [ { name: 'areaGroup', header: { text: '행정구역' }, items: ['SIDO', 'GUGUN'] }, + '구분', 'TARGET_YR', 'PIPL_IMPRMN_PLAN_EXTN', 'PIPL_PLAN_EXTN', 'PIPL_FCLTY_EXTN', 'PIPL_SUPPRT', { name: 'combinedGroup', header: { text: '합류식(m)' }, diff --git a/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts b/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts index a1133c75e..49e962de2 100644 --- a/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts +++ b/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts @@ -1,39 +1,175 @@ import { ValueType } from "realgrid"; -import { footerInt, footerDecimal3 } from "@/components/util/grid/footerSummary"; +import { footerInt, footerDecimal3, footerIntArrayByDivision, footerDecimal3ArrayByDivision } from "@/components/util/grid/footerSummary"; +import type { GridView, DataColumn, ColumnFooter } from "@/components/type/DataGrid.type"; // 하수도 통계 관리 01. 하수도 보급률 export const SeweragePenetrationRateInfoList = { columns: [ - { name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true, footer: { text: '합계', multiRow: true } }, + { + name: 'sido', + fieldName: '시도', + header: { text: '시도' }, + mergeCells: true, + editable: false, + sortdisable: true, + footer: [ + { + valueCallback: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined + ) => { + // 첫 번째 footer 행: 첫 번째 "구분" 값 + "합계" + const divisionIndex = 0; + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + const uniqueValues = new Set(); + + for (let i = 0; i < rowCount; i++) { + const divisionValue = dataSource.getValue(i, '구분'); + if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') { + uniqueValues.add(String(divisionValue)); + } + } + + const divisionValues = Array.from(uniqueValues); + if (divisionValues.length > divisionIndex) { + return divisionValues[divisionIndex] + '합계'; + } + return '합계'; + } + }, + { + valueCallback: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined + ) => { + // 두 번째 footer 행: 두 번째 "구분" 값 + "합계" + const divisionIndex = 1; + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + const uniqueValues = new Set(); + + for (let i = 0; i < rowCount; i++) { + const divisionValue = dataSource.getValue(i, '구분'); + if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') { + uniqueValues.add(String(divisionValue)); + } + } + + const divisionValues = Array.from(uniqueValues); + if (divisionValues.length > divisionIndex) { + return divisionValues[divisionIndex] + '합계'; + } + return '합계'; + } + } + ] + }, { name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true }, - { name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true }, + { + name: '구분', + fieldName: '구분', + header: { text: '구분' }, + editable: false, + sortdisable: true, + footer: [ + { + valueCallback: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined + ) => { + // 첫 번째 footer 행: 첫 번째 "구분" 값 사용 (footerIndex는 0이어야 함) + const divisionIndex = 0; // 첫 번째 footer 요소는 항상 첫 번째 "구분" 값 사용 + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + const uniqueValues = new Set(); + + for (let i = 0; i < rowCount; i++) { + const divisionValue = dataSource.getValue(i, '구분'); + if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') { + uniqueValues.add(String(divisionValue)); + } + } + + const divisionValues = Array.from(uniqueValues); + console.log(`[구분 footer] footerIndex: ${footerIndex}, divisionIndex: ${divisionIndex}, divisionValues:`, divisionValues); + if (divisionValues.length > divisionIndex) { + const result = divisionValues[divisionIndex] + '합계'; + console.log(`[구분 footer] result: ${result}`); + return result; + } + return ''; + } + }, + { + valueCallback: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined + ) => { + // 두 번째 footer 행: 두 번째 "구분" 값 사용 (footerIndex는 1이어야 함) + const divisionIndex = 1; // 두 번째 footer 요소는 항상 두 번째 "구분" 값 사용 + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + const uniqueValues = new Set(); + + for (let i = 0; i < rowCount; i++) { + const divisionValue = dataSource.getValue(i, '구분'); + if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') { + uniqueValues.add(String(divisionValue)); + } + } + + const divisionValues = Array.from(uniqueValues); + console.log(`[구분 footer] footerIndex: ${footerIndex}, divisionIndex: ${divisionIndex}, divisionValues:`, divisionValues); + if (divisionValues.length > divisionIndex) { + const result = divisionValues[divisionIndex] + '합계'; + console.log(`[구분 footer] result: ${result}`); + return result; + } + return ''; + } + } + ] + }, { name: '수계', fieldName: '수계', header: { text: '수계' } }, { name: 'bstrm', fieldName: '지류', header: { text: '지류' } }, { name: 'mdbsn', fieldName: '세부단위유역', header: { text: '세부단위유역' } }, { name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' } }, { name: 'mdmbsn', fieldName: '소권역', header: { text: '소권역' } }, { name: 'specl_cntrpln_ar', fieldName: '특별대책지역', header: { text: '특별대책지역' } }, - { name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, - { name: '계', fieldName: '계', header: { text: '계' } }, + { name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, footer: footerIntArrayByDivision, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + { name: '계', fieldName: '계', header: { text: '계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, //공공하수처리인구 - { name: '합계', fieldName: '합계', header: { text: '합계' } }, - { name: '1차처리', fieldName: '1차처리', header: { text: '1차처리' } }, - { name: '2차처리', fieldName: '2차처리', header: { text: '2차처리' } }, - { name: '3차처리', fieldName: '3차처리', header: { text: '3차처리' } }, + { name: '합계', fieldName: '합계', header: { text: '합계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: '1차처리', fieldName: '1차처리', header: { text: '1차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: '2차처리', fieldName: '2차처리', header: { text: '2차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: '3차처리', fieldName: '3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, //폐수처리인구(명) - { name: '(폐수처리인구)합계', fieldName: '(폐수처리인구)합계', header: { text: '합계' } }, - { name: '(폐수처리인구)1차처리', fieldName: '(폐수처리인구)1차처리', header: { text: '1차처리' } }, - { name: '(폐수처리인구)2차처리', fieldName: '(폐수처리인구)2차처리', header: { text: '2차처리' } }, - { name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' } }, + { name: '(폐수처리인구)합계', fieldName: '(폐수처리인구)합계', header: { text: '합계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: '(폐수처리인구)1차처리', fieldName: '(폐수처리인구)1차처리', header: { text: '1차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: '(폐수처리인구)2차처리', fieldName: '(폐수처리인구)2차처리', header: { text: '2차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' } }, - { name: 'swge_proc_zone_inner_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' } }, - { name: 'swge_proc_zone_outer_sm', fieldName: '합계', header: { text: '계' } }, - { name: 'town_unproc_popltn', fieldName: '미처리인구', header: { text: '미처리인구' } }, - { name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' } }, - { name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' } }, - { name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' } }, + { name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'swge_proc_zone_inner_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + { name: 'swge_proc_zone_outer_sm', fieldName: '합계', header: { text: '계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'town_unproc_popltn', fieldName: '미처리인구', header: { text: '미처리인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, { name: 'pblswrg_popltn_suprt', fieldName: '공공하수처리구역인구보급율(%)', header: { text: '공공하수처리구역인구보급율(%)' } }, { name: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급율(%)', header: { text: `고도처리인구보급율(%)` } }, { name: 'swrg_instlrt', fieldName: '하수도설치율(%)', header: { text: '하수도설치율(%)' } }, @@ -45,7 +181,7 @@ export const SeweragePenetrationRateInfoList = { hideChildHeaders: false, header: { text: '행정구역' }, items: [ - { column: 'sido', footerUserSpans: [{ colspan: 3 }] }, + { column: 'sido', footerUserSpans: [{ colspan: 3 }, { colspan: 3 }] }, // 첫 번째 footer 행과 두 번째 footer 행 모두 병합 'sgg', ] }, diff --git a/apps/st/src/components/util/grid/footerSummary.ts b/apps/st/src/components/util/grid/footerSummary.ts index 300752cd5..78cc0b601 100644 --- a/apps/st/src/components/util/grid/footerSummary.ts +++ b/apps/st/src/components/util/grid/footerSummary.ts @@ -23,6 +23,65 @@ export const getFooterSummary = (grid: GridView, column: DataColumn) => { return sum; }; +// "구분" 필드의 고유값들을 찾는 함수 (첫 번째, 두 번째) +const getDivisionValues = (grid: GridView, divisionFieldName: string = '구분'): string[] => { + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + const uniqueValues = new Set(); + + for (let i = 0; i < rowCount; i++) { + const divisionValue = dataSource.getValue(i, divisionFieldName); + if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') { + uniqueValues.add(String(divisionValue)); + } + } + + // Set을 배열로 변환하고 순서 유지 + return Array.from(uniqueValues); +}; + +// "구분" 필드 값에 따라 필터링하여 합계 계산하는 함수 +// divisionIndex: 0 = 첫 번째 고유값, 1 = 두 번째 고유값 +export const getFooterSummaryByDivision = ( + grid: GridView, + column: DataColumn, + divisionIndex: number, + divisionFieldName: string = '구분' +) => { + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + let sum = 0; + + // "구분" 필드의 고유값 찾기 + const divisionValues = getDivisionValues(grid, divisionFieldName); + if (divisionValues.length <= divisionIndex) { + return 0; // 해당 인덱스의 고유값이 없으면 0 반환 + } + + const targetDivision = divisionValues[divisionIndex]; + + for (let i = 0; i < rowCount; i++) { + // "구분" 필드 값 확인 + const divisionValue = dataSource.getValue(i, divisionFieldName); + if (String(divisionValue) !== targetDivision) { + continue; // 해당 "구분" 값이 아니면 스킵 + } + + const cellValue = dataSource.getValue(i, column.fieldName); + + // 문자열인 경우 숫자로 변환 + const numericValue = typeof cellValue === 'string' + ? Number(String(cellValue).replace(/,/g, '')) + : Number(cellValue); + + if (!isNaN(numericValue) && numericValue !== null && numericValue !== undefined) { + sum += numericValue; + } + } + + return sum; +}; + // Footer 설정 객체 생성 함수 const createSumFooter = (format: string) => ({ expression: 'sum' as const, @@ -32,6 +91,38 @@ const createSumFooter = (format: string) => ({ }, }); +// "구분" 값에 따라 필터링하는 Footer 배열 생성 함수 (2줄) +const createSumFooterArrayByDivision = (format: string) => [ + { + numberFormat: format, + valueCallback: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: unknown, + value: number | string | undefined + ) => { + // footerIndex: 0 = 첫 번째 footer 행 (첫 번째 "구분" 값), 1 = 두 번째 footer 행 (두 번째 "구분" 값) + // footerIndex를 divisionIndex로 사용 + return getFooterSummaryByDivision(grid, column, footerIndex); + }, + }, + { + numberFormat: format, + valueCallback: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: unknown, + value: number | string | undefined + ) => { + // footerIndex: 0 = 첫 번째 footer 행 (첫 번째 "구분" 값), 1 = 두 번째 footer 행 (두 번째 "구분" 값) + // footerIndex를 divisionIndex로 사용 + return getFooterSummaryByDivision(grid, column, footerIndex); + }, + }, +]; + // 1. 정수형 : #,##0 export const footerInt = createSumFooter('#,##0'); @@ -42,4 +133,10 @@ export const footerDecimal1 = createSumFooter('#,##0.0'); export const footerDecimal2 = createSumFooter('#,##0.00'); // 4. 소수점 3자리 : #,##0.000 -export const footerDecimal3 = createSumFooter('#,##0.000'); \ No newline at end of file +export const footerDecimal3 = createSumFooter('#,##0.000'); + +// "구분" 값에 따라 필터링하는 Footer 배열 (정수형, 2줄) +export const footerIntArrayByDivision = createSumFooterArrayByDivision('#,##0'); + +// "구분" 값에 따라 필터링하는 Footer 배열 (소수점 3자리, 2줄) +export const footerDecimal3ArrayByDivision = createSumFooterArrayByDivision('#,##0.000'); \ No newline at end of file diff --git a/packages/rsone/.gitignore b/packages/rsone/.gitignore index 3ece3c8a7..cf52894f8 100644 --- a/packages/rsone/.gitignore +++ b/packages/rsone/.gitignore @@ -23,4 +23,7 @@ dist-ssr *.sln *.sw? -.env* \ No newline at end of file +.env* + +# TypeScript +*.tsbuildinfo \ No newline at end of file diff --git a/packages/rsone/components/grid/CompactDataGrid.tsx b/packages/rsone/components/grid/CompactDataGrid.tsx index 1312230a9..5fdc0d7db 100644 --- a/packages/rsone/components/grid/CompactDataGrid.tsx +++ b/packages/rsone/components/grid/CompactDataGrid.tsx @@ -18,6 +18,31 @@ declare module "realgrid" { 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; + getValue?(row: number, field: string | number): unknown; +} + +/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ +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; + const val1 = get(row1, field); + const val2 = get(row2, field); + if (val1 == null && val2 == null) return 0; + if (val1 == null) return 1; + if (val2 == null) return -1; + const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1)); + const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2)); + if (isNaN(num1) && isNaN(num2)) return 0; + if (isNaN(num1)) return 1; + if (isNaN(num2)) return -1; + return num1 > num2 ? 1 : num1 < num2 ? -1 : 0; + }; +} + const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -28,54 +53,21 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; - const fieldsToSet: Array<{ fieldName: string; dataType: string }> = []; - const fieldMap = new Map(); - - columns.forEach(col => { - if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) { - fieldMap.set(col.fieldName, col.valueType); - fieldsToSet.push({ - fieldName: col.fieldName, - dataType: col.valueType - }); - } - }); - - // 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트 - if (fieldsToSet.length > 0) { - try { - // 기존 필드 정보 가져오기 - const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || []; - const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text'])); - - // valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트 - const fieldsToUpdate = fieldsToSet.filter(field => { - const existingDataType = existingFieldMap.get(field.fieldName); - return existingDataType !== field.dataType; - }); - - if (fieldsToUpdate.length > 0) { - // 기존 필드 정보를 유지하면서 dataType만 업데이트 - // 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용 - const allFields = existingFields.map(f => ({ - fieldName: f.fieldName, - dataType: fieldMap.get(f.fieldName) || f.dataType || 'text' - })); - - // 새로 추가할 필드가 있으면 추가 - fieldsToSet.forEach(field => { - if (!existingFieldMap.has(field.fieldName)) { - allFields.push({ fieldName: field.fieldName, dataType: field.dataType }); - } - }); - - // setFields를 호출하여 필드의 dataType 업데이트 - (dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void }) - .setFields(allFields); + // setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음) + const dp = dataProvider as RealGridDataProviderComparer; + if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") { + const numberComparer = createNumberComparer(dp); + const seen = new Set(); + columns.forEach((col) => { + if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) { + seen.add(col.fieldName); + try { + dp.setDataComparer!(col.fieldName, numberComparer); + } catch (e) { + console.warn("setDataComparer:", e); + } } - } catch (error) { - console.warn('Failed to set field dataType:', error); - } + }); } gridView.setDisplayOptions({ @@ -92,7 +84,25 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, // footer가 있는 컬럼이 있으면 footer 높이 설정 const hasFooter = columns.some(col => col.footer); if (hasFooter) { - gridView.footer.height = 40; + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + 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; + }); + 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 })); + gridView.setFooters?.(footerHeights); + } else { + gridView.footer.height = 40; + } } // 고정 컬럼 설정 @@ -106,15 +116,112 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, summaryMode: "aggregate" }); - // footer의 valueCallback 설정 - columns.forEach(col => { - if (col.footer && col.footer.valueCallback) { - const column = gridView.columnByName(col.name); - if (column && column.footer) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + // 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 checkFooterUserSpans(item.items); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 + setTimeout(() => { + if (gridRef.current?.gridView && columnLayout) { + gridRef.current.gridView.setColumnLayout?.(columnLayout); + } + }, 100); + } + + // footer 설정 (배열 또는 단일 객체 모두 처리) + // setFooters 호출 후 footer 배열 설정 + setTimeout(() => { + const currentGridView = gridRef.current?.gridView; + if (!currentGridView || !currentGridView.columnByName) { + console.warn('GridView not available for footer setup'); + return; + } + + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 + const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + if (hasArrayFooter) { + // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 + const columnsWithFooter = columns.map(col => { + const colInfo: any = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + }; + + 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; + }); + + try { + (currentGridView as any).setColumns?.(columnsWithFooter); + + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 + setTimeout(() => { + columns.forEach(col => { + if (!col.footer || !Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column) return; + + // footer 배열의 각 요소에 valueCallback 설정 + col.footer.forEach((footerItem, index) => { + if (footerItem.valueCallback && column.footer) { + // footer가 배열인 경우 + if (Array.isArray(column.footer) && column.footer[index]) { + (column.footer[index] as any).valueCallback = footerItem.valueCallback; + } + } + }); + } catch (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); } } - }); + + // footer가 단일 객체이고 valueCallback이 있는 경우 + 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; + } + } catch (e) { + console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + } + }); + }, 200); }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]); @@ -136,7 +243,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} header={column.header} editable={column.editable} - footer={column.footer ? { + footer={column.footer && !Array.isArray(column.footer) ? { text: column.footer.text, expression: column.footer.expression, numberFormat: column.footer.numberFormat, diff --git a/packages/rsone/components/grid/DataGrid.tsx b/packages/rsone/components/grid/DataGrid.tsx index 21d921130..f3679f5fa 100644 --- a/packages/rsone/components/grid/DataGrid.tsx +++ b/packages/rsone/components/grid/DataGrid.tsx @@ -6,8 +6,10 @@ import { RealGridReact, RGDataColumn } from "realgrid-react" import type { DataGridProps } from "../type/DataGrid.type" import JSZip from "jszip" import * as XLSX from "xlsx" -import { Button } from "@common/components"; + import { useAlertStore } from "../store/alert-store"; + +import { Button } from "@common/components"; import { useTabStore } from "../store/tab-store"; // RealGrid 타입 확장 @@ -35,10 +37,245 @@ if (typeof window !== "undefined") { 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; + getValue?(row: number, field: string | number): unknown; +} + +/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ +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; + const val1 = get(row1, field); + const val2 = get(row2, field); + if (val1 == null && val2 == null) return 0; + if (val1 == null) return 1; + if (val2 == null) return -1; + const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1)); + const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2)); + if (isNaN(num1) && isNaN(num2)) return 0; + if (isNaN(num1)) return 1; + if (isNaN(num2)) return -1; + return num1 > num2 ? 1 : num1 < num2 ? -1 : 0; + }; +} + +/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ +function toNum(v: unknown): number { + if (v == null) return NaN; + if (typeof v === "number") return isNaN(v) ? NaN : v; + return parseFloat(String(v).replace(/,/g, "")) || NaN; +} + +/** 헤더에 정렬 화살표 표시 (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; + }, + columns: { fieldName?: string; name: string }[], + 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); + if (!c) break; + if (c.fieldName === (col.fieldName || col.name) || c.name === (col.fieldName || col.name)) { + gridCol = c; + break; + } + } + if (!gridCol) { + gridCol = gridView.columnByName?.(col.name); + } + + if (!gridCol) return; + + 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 gridEl = gridEl1 || gridEl2; + if (!gridEl) return; + + // 헤더 셀 찾기 (fieldName 또는 name으로) + 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)))) { + targetCell = el; + } + }); + + if (!targetCell) return; + + const headerCell = targetCell as HTMLElement; + + // 기존 화살표 제거 + const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null; + if (existingIndicator) { + existingIndicator.remove(); + } + + // 화살표 추가 + if (direction === "asc" || direction === "desc") { + const indicator = document.createElement("span"); + indicator.className = "rg-sort-indicator"; + indicator.textContent = direction === "asc" ? "▲" : "▼"; + indicator.style.marginLeft = "4px"; + indicator.style.fontSize = "10px"; + indicator.style.color = "#666"; + indicator.style.display = "inline-block"; + indicator.style.verticalAlign = "middle"; + headerCell.appendChild(indicator); + } + }); + } catch (e) { + console.warn("updateHeaderSortIndicator error:", e); + } +} + +/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */ +function createOnSortingHandler( + columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[], + setRowData: React.Dispatch[]>>, + sortDirRef: React.MutableRefObject>, + originalRowsRef: React.MutableRefObject[]>> +) { + 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 groupField = mergeCellCol?.fieldName || null; + const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음 + 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 } }; + 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; + } + if (!sortField) return true; + 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; + rows.push(r); + } + const prev = sortDirRef.current[sortField] ?? "none"; + const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds); + + if (prev === "desc") { + const orig = originalRowsRef.current[sortField]; + if (orig && setRows) { + setRows(orig); + setRowData(orig); + } + sortDirRef.current[sortField] = "none"; + return false; + } + if (prev === "none") { + originalRowsRef.current[sortField] = rows.map((r) => ({ ...r })); + } + const sortAsc = prev !== "asc"; + sortDirRef.current[sortField] = sortAsc ? "asc" : "desc"; + const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType); + setRows?.(sorted); + setRowData(sorted); + return false; + }; +} + +/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */ +function sortRowsGroupPreserving( + rows: Record[], + sortField: string, + sortAsc: boolean, + opt: { groupField: string; orderByField?: string }, + valueType?: ValueType +): Record[] { + const groups = new Map[]>(); + for (const r of rows) { + const k = String(r[opt.groupField] ?? ""); + if (!groups.has(k)) groups.set(k, []); + groups.get(k)!.push(r); + } + + // valueType에 따라 비교 함수 선택 + const compareValue = (val: unknown): number | string => { + if (valueType === ValueType.NUMBER) { + return toNum(val); + } + // 텍스트나 기타 타입은 문자열로 비교 + 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)); + 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"); + if (strs.length === 0) return sortAsc ? "\uffff" : ""; + strs.sort(); + 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]); + if (typeof valA === "number" && typeof valB === "number") { + return (sortAsc ? 1 : -1) * (valA - valB); + } + // 문자열 비교 + const strA = String(valA); + const strB = String(valB); + return (sortAsc ? 1 : -1) * strA.localeCompare(strB); + }); + + return entries.flatMap(([, arr]) => arr); +} + const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); const fileInputRef = useRef(null); + const sortDirRef = useRef>({}); + const originalRowsRef = useRef[]>>({}); const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); const menuList = useTabStore((state) => state.menuList); @@ -208,68 +445,61 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; - const fieldsToSet: Array<{ fieldName: string; dataType: string }> = []; - const fieldMap = new Map(); + // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 + const hasMergeCells = columns.some((col) => col.mergeCells === true); - columns.forEach(col => { - if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) { - fieldMap.set(col.fieldName, col.valueType); - fieldsToSet.push({ - fieldName: col.fieldName, - dataType: col.valueType - }); - } - }); - - // 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트 - if (fieldsToSet.length > 0) { - try { - // 기존 필드 정보 가져오기 - const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || []; - const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text'])); - - // valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트 - const fieldsToUpdate = fieldsToSet.filter(field => { - const existingDataType = existingFieldMap.get(field.fieldName); - return existingDataType !== field.dataType; - }); - - if (fieldsToUpdate.length > 0) { - // 기존 필드 정보를 유지하면서 dataType만 업데이트 - // 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용 - const allFields = existingFields.map(f => ({ - fieldName: f.fieldName, - dataType: fieldMap.get(f.fieldName) || f.dataType || 'text' - })); - - // 새로 추가할 필드가 있으면 추가 - fieldsToSet.forEach(field => { - if (!existingFieldMap.has(field.fieldName)) { - allFields.push({ fieldName: field.fieldName, dataType: field.dataType }); - } - }); - - // setFields를 호출하여 필드의 dataType 업데이트 - (dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void }) - .setFields(allFields); + // setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리) + const dp = dataProvider as RealGridDataProviderComparer; + 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)) { + seen.add(col.fieldName); + try { + dp.setDataComparer!(col.fieldName, numberComparer); + } catch (e) { + console.warn("setDataComparer:", e); + } } - } catch (error) { - console.warn('Failed to set field dataType:', error); - } + }); } + // mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용 + (gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + (gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN; gridView.header.height = 60; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ checkBar: { visible: showHeaderCheckbox }, + rowIndicator: { visible: false }, }); // footer가 있는 컬럼이 있으면 footer 높이 설정 const hasFooter = columns.some(col => col.footer); if (hasFooter) { - gridView.footer.height = 40; + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + 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; + }); + 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 })); + gridView.setFooters?.(footerHeights); + } else { + gridView.footer.height = 40; + } } // 고정 컬럼 설정 @@ -283,18 +513,116 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport summaryMode: "aggregate" }); - // footer의 valueCallback 설정 - columns.forEach(col => { - if (col.footer && col.footer.valueCallback) { - const column = gridView.columnByName(col.name); - if (column && column.footer) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + // 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 checkFooterUserSpans(item.items); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 + setTimeout(() => { + if (gridRef.current?.gridView && columnLayout) { + gridRef.current.gridView.setColumnLayout?.(columnLayout); + } + }, 100); + } + + // footer 설정 (배열 또는 단일 객체 모두 처리) + // setFooters 호출 후 footer 배열 설정 + setTimeout(() => { + const currentGridView = gridRef.current?.gridView; + if (!currentGridView || !currentGridView.columnByName) { + console.warn('GridView not available for footer setup'); + return; + } + + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 + const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + if (hasArrayFooter) { + // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 + const columnsWithFooter = columns.map(col => { + const colInfo: any = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + }; + + 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; + }); + + try { + (currentGridView as any).setColumns?.(columnsWithFooter); + + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 + setTimeout(() => { + columns.forEach(col => { + if (!col.footer || !Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column) return; + + // footer 배열의 각 요소에 valueCallback 설정 + col.footer.forEach((footerItem, index) => { + if (footerItem.valueCallback && column.footer) { + // footer가 배열인 경우 + if (Array.isArray(column.footer) && column.footer[index]) { + (column.footer[index] as any).valueCallback = footerItem.valueCallback; + } + } + }); + } catch (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); } } - }); + + // footer가 단일 객체이고 valueCallback이 있는 경우 + 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; + } + } catch (e) { + console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + } + }); + }, 200); }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]); + return (
- + { + (grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + }} + > {columns.map((column) => ( [], options?: { fillMode?: string }): void; +} + +/** 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; + getValue?(row: number, field: string | number): unknown; +} + // Window 인터페이스 확장 declare global { interface Window { @@ -38,10 +48,252 @@ if (typeof window !== "undefined") { RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ''); +/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ +function toNum(v: unknown): number { + if (v == null) return NaN; + if (typeof v === "number") return isNaN(v) ? NaN : v; + return parseFloat(String(v).replace(/,/g, "")) || NaN; +} + +/** 헤더에 정렬 화살표 표시 (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; + getElement?: () => HTMLElement | null; + container?: HTMLElement; + }, + columns: { fieldName?: string; name: string }[], + sortDir: Record, + gridElement?: HTMLElement | null +) { + try { + // 그리드 DOM 요소 찾기 + let gridEl: HTMLElement | null = null; + if (gridElement) { + gridEl = gridElement; + } else if (gridView.getElement) { + gridEl = gridView.getElement(); + } else if (gridView.container) { + gridEl = gridView.container; + } else { + // 폴백: 모든 그리드 중에서 찾기 + const allGrids = document.querySelectorAll('.rg-grid, [data-grid-id]'); + if (allGrids.length > 0) { + // 활성 탭의 그리드를 찾기 위해 TabsContent 내부의 그리드 찾기 + const activeTabContent = document.querySelector('[data-state="active"]')?.closest('[role="tabpanel"]'); + if (activeTabContent) { + gridEl = activeTabContent.querySelector('.rg-grid, [data-grid-id]') as HTMLElement; + } + if (!gridEl && allGrids.length === 1) { + gridEl = allGrids[0] as HTMLElement; + } + } + } + + if (!gridEl) return; + + columns.forEach((col) => { + if (!col.fieldName && !col.name) return; + + const fieldName = col.fieldName || col.name; + const direction = sortDir[fieldName] || sortDir[col.name] || "none"; + if (direction === "none") return; + + // 헤더 셀 찾기 (fieldName 또는 name으로) + const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"], .rg-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 === colName || + text === colFieldName || + 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; + if (existingIndicator) { + existingIndicator.remove(); + } + + // 화살표 추가 + const indicator = document.createElement("span"); + indicator.className = "rg-sort-indicator"; + indicator.textContent = direction === "asc" ? "▲" : "▼"; + indicator.style.marginLeft = "4px"; + indicator.style.fontSize = "10px"; + indicator.style.color = "#666"; + indicator.style.display = "inline-block"; + indicator.style.verticalAlign = "middle"; + headerCell.appendChild(indicator); + }); + } catch (e) { + console.warn("updateHeaderSortIndicator error:", e); + } +} + +/** 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[]>>, + 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 groupField = mergeCellCol?.fieldName || null; + const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음 + 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 } }; + 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; + } + if (!sortField) return true; + 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; + rows.push(r); + } + const prev = sortDirRef.current[sortField] ?? "none"; + const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds); + + if (prev === "desc") { + const orig = originalRowsRef.current[sortField]; + if (orig && setRows) { + setRows(orig); + setRowData(orig); + } + sortDirRef.current[sortField] = "none"; + return false; + } + if (prev === "none") { + originalRowsRef.current[sortField] = rows.map((r) => ({ ...r })); + } + const sortAsc = prev !== "asc"; + sortDirRef.current[sortField] = sortAsc ? "asc" : "desc"; + const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType); + setRows?.(sorted); + setRowData(sorted); + return false; + }; +} + +/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */ +function sortRowsGroupPreserving( + rows: Record[], + sortField: string, + sortAsc: boolean, + opt: { groupField: string; orderByField?: string }, + valueType?: ValueType +): Record[] { + const groups = new Map[]>(); + for (const r of rows) { + const k = String(r[opt.groupField] ?? ""); + if (!groups.has(k)) groups.set(k, []); + groups.get(k)!.push(r); + } + + // valueType에 따라 비교 함수 선택 + const compareValue = (val: unknown): number | string => { + if (valueType === ValueType.NUMBER) { + return toNum(val); + } + // 텍스트나 기타 타입은 문자열로 비교 + 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)); + 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"); + if (strs.length === 0) return sortAsc ? "\uffff" : ""; + strs.sort(); + 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]); + if (typeof valA === "number" && typeof valB === "number") { + return (sortAsc ? 1 : -1) * (valA - valB); + } + // 문자열 비교 + const strA = String(valA); + const strB = String(valB); + return (sortAsc ? 1 : -1) * strA.localeCompare(strB); + }); + + return entries.flatMap(([, arr]) => arr); +} + +/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ +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; + const val1 = get(row1, field); + const val2 = get(row2, field); + if (val1 == null && val2 == null) return 0; + if (val1 == null) return 1; + if (val2 == null) return -1; + const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1)); + const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2)); + if (isNaN(num1) && isNaN(num2)) return 0; + if (isNaN(num1)) return 1; + if (isNaN(num2)) return -1; + return num1 > num2 ? 1 : num1 < num2 ? -1 : 0; + }; +} + const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => { // 각 탭별로 그리드 ref와 데이터를 관리 const gridRefs = useRef>(new Map()); const fileInputRefs = useRef>(new Map()); + // 각 탭별로 정렬 상태 관리 + const sortDirRefs = useRef>>>(new Map()); + const originalRowsRefs = useRef[]>>>>(new Map()); const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); const menuList = useTabStore((state) => state.menuList); @@ -244,6 +496,30 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r } }, [rows, columnInfo]); + // rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등) + useEffect(() => { + columnInfo.forEach((info) => { + const tabKey = info.tabKey; + const gridRef = gridRefs.current.get(tabKey); + const currentData = rowDataMap.get(tabKey); + + if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) { + setTimeout(() => { + try { + const dp = gridRef.dataProvider as RealGridDataProviderFill | null; + if (dp?.fillJsonData) { + dp.fillJsonData(currentData, { fillMode: "set" }); + } + gridRef.gridView.refresh(); + } catch (error) { + console.error("그리드 데이터 설정 오류:", error); + } + }, 100); + } + }); + }, [rowDataMap, columnInfo]); + + // 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때) useEffect(() => { const gridRef = gridRefs.current.get(activeTab); @@ -378,7 +654,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }
-
+
{ if (el) { @@ -391,6 +667,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const currentInfo = columnInfo.find(info => info.tabKey === tabKey); if (!currentInfo || !currentInfo.columns) return; + + const columns = currentInfo.columns; gridView.setDisplayOptions({ fitStyle: GridFitStyle.EVEN @@ -404,24 +682,140 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }); // footer가 있는 컬럼이 있으면 footer 높이 설정 - const hasFooter = currentInfo.columns.some(col => col.footer); + const hasFooter = columns.some(col => col.footer); if (hasFooter) { - gridView.footer.height = 40; + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + 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; + }); + 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 })); + gridView.setFooters?.(footerHeights); + } else { + gridView.footer.height = 40; + } } gridView.setOptions({ summaryMode: "aggregate" }); - // footer의 valueCallback 설정 - currentInfo.columns.forEach(col => { - if (col.footer && col.footer.valueCallback) { - const column = gridView.columnByName(col.name); - if (column && column.footer) { - (column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback; + // 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 checkFooterUserSpans(item.items); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout)) { + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 + setTimeout(() => { + if (gridView && columnLayout) { + gridView.setColumnLayout?.(columnLayout); + } + }, 100); + } + + // footer 설정 (배열 또는 단일 객체 모두 처리) + // setFooters 호출 후 footer 배열 설정 + setTimeout(() => { + const currentGridView = gridView; + if (!currentGridView || !currentGridView.columnByName) { + console.warn('GridView not available for footer setup'); + return; + } + + // footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정 + const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); + if (hasArrayFooter) { + // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 + const columnsWithFooter = columns.map(col => { + const colInfo: any = { + name: col.name, + fieldName: col.fieldName, + header: col.header, + editable: col.editable, + valueType: col.valueType, + dataFormat: col.dataFormat, + }; + + 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; + }); + + try { + (currentGridView as any).setColumns?.(columnsWithFooter); + + // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 + setTimeout(() => { + columns.forEach(col => { + if (!col.footer || !Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column) return; + + // footer 배열의 각 요소에 valueCallback 설정 + col.footer.forEach((footerItem, index) => { + if (footerItem.valueCallback && column.footer) { + // footer가 배열인 경우 + if (Array.isArray(column.footer) && column.footer[index]) { + (column.footer[index] as any).valueCallback = footerItem.valueCallback; + } + } + }); + } catch (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); } } - }); + + // footer가 단일 객체이고 valueCallback이 있는 경우 + columns.forEach(col => { + if (!col.footer || Array.isArray(col.footer)) return; + + try { + const column = currentGridView.columnByName(col.name); + if (!column || !column.footer) return; + + const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown }; + if (footerObj.valueCallback) { + (column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback; + } + } catch (e) { + console.warn(`Error setting footer valueCallback for column ${col.name}:`, e); + } + }); + }, 200); // 고정 컬럼 설정 if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) { @@ -430,60 +824,60 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }); } - // valueType이 설정된 컬럼에 대해 필드의 dataType을 명시적으로 설정 - // autoGenerateField={true}일 때 valueType prop만으로는 필드의 dataType이 설정되지 않을 수 있음 + // 각 탭별 ref 초기화 + if (!sortDirRefs.current.has(tabKey)) { + sortDirRefs.current.set(tabKey, { current: {} }); + } + if (!originalRowsRefs.current.has(tabKey)) { + originalRowsRefs.current.set(tabKey, { current: {} }); + } + + const sortDirRef = sortDirRefs.current.get(tabKey)!; + const originalRowsRef = originalRowsRefs.current.get(tabKey)!; + + // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 + const hasMergeCells = columns.some((col) => col.mergeCells === true); + + // setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리) if (el.dataProvider) { - const dataProvider = el.dataProvider; - const fieldsToSet: Array<{ fieldName: string; dataType: string }> = []; - const fieldMap = new Map(); - - currentInfo.columns.forEach(col => { - if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) { - fieldMap.set(col.fieldName, col.valueType); - fieldsToSet.push({ - fieldName: col.fieldName, - dataType: col.valueType - }); - } - }); - - // 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트 - if (fieldsToSet.length > 0) { - try { - // 기존 필드 정보 가져오기 - const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || []; - const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text'])); - - // valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트 - const fieldsToUpdate = fieldsToSet.filter(field => { - const existingDataType = existingFieldMap.get(field.fieldName); - return existingDataType !== field.dataType; - }); - - if (fieldsToUpdate.length > 0) { - // 기존 필드 정보를 유지하면서 dataType만 업데이트 - // 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용 - const allFields = existingFields.map(f => ({ - fieldName: f.fieldName, - dataType: fieldMap.get(f.fieldName) || f.dataType || 'text' - })); - - // 새로 추가할 필드가 있으면 추가 - fieldsToSet.forEach(field => { - if (!existingFieldMap.has(field.fieldName)) { - allFields.push({ fieldName: field.fieldName, dataType: field.dataType }); - } - }); - - // setFields를 호출하여 필드의 dataType 업데이트 - (dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void }) - .setFields(allFields); + const dp = el.dataProvider as RealGridDataProviderComparer; + 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)) { + seen.add(col.fieldName); + try { + dp.setDataComparer!(col.fieldName, numberComparer); + } catch (e) { + console.warn("setDataComparer:", e); + } } - } catch (error) { - console.warn('Failed to set field dataType:', error); - } + }); } } + + // mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용 + const setRowData = (updater: React.SetStateAction[]>) => { + setRowDataMap(prev => { + const newMap = new Map(prev); + const currentData = newMap.get(tabKey) || []; + const newData = typeof updater === 'function' ? updater(currentData) : updater; + newMap.set(tabKey, newData); + return newMap; + }); + }; + + // 그리드 DOM 요소 찾기 (나중에 화살표 업데이트에 사용) + // 탭별로 그리드 요소를 찾기 위해 data-tab-key 사용 + const findGridElement = () => { + const tabContent = document.querySelector(`[data-tab-key="${tabKey}"]`); + return tabContent?.querySelector('.rg-grid, [data-grid-id]') as HTMLElement | null; + }; + + const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement); + (gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = onSortingHandler; }, 0); } } else { @@ -503,10 +897,10 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })} header={column.header} editable={column.editable} - footer={column.footer ? { - text: column.footer.text, - expression: column.footer.expression, - numberFormat: column.footer.numberFormat, + footer={column.footer && !Array.isArray(column.footer) ? { + text: (column.footer as { text?: string }).text, + expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression, + numberFormat: (column.footer as { numberFormat?: string }).numberFormat, } : undefined} mergeRule={column.mergeCells ? { criteria: 'value' } : undefined} sortable={!column.sortdisable} diff --git a/packages/rsone/components/type/DataGrid.type.ts b/packages/rsone/components/type/DataGrid.type.ts index 2717f4ff8..9f9e16a70 100644 --- a/packages/rsone/components/type/DataGrid.type.ts +++ b/packages/rsone/components/type/DataGrid.type.ts @@ -25,6 +25,12 @@ export type ColumnFooter = { numberFormat?: string; }; +/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */ +export type SortPreserveGroupOrder = { + groupField: string; + orderByField?: string; +}; + export type ColumnConfig = { name: string; fieldName: string; @@ -32,6 +38,7 @@ export type ColumnConfig = { editable?: boolean; // 셀 편집 가능 여부 mergeCells?: boolean; sortdisable?: boolean; + sortPreserveGroupOrder?: SortPreserveGroupOrder; valueType?: ValueType; dataFormat?: string; footer?: { @@ -39,7 +46,19 @@ export type ColumnConfig = { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min'; numberFormat?: string; valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string; - }, + multiRow?: boolean; // 여러 행 footer 사용 여부 + } | Array<{ + text?: string; + expression?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + numberFormat?: string; + valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string; + }>, // RealGrid에서 footer를 배열로 설정할 수 있음 +}; + +export type FooterUserSpan = { + rowspan?: number; // 병합할 행 수 + colspan?: number; // 병합할 컬럼 수 + text?: string; // 표시할 텍스트 }; export type ColumnLayoutItem = string | { @@ -47,6 +66,10 @@ export type ColumnLayoutItem = string | { hideChildHeaders?: boolean; header: { text: string }; items: (string | ColumnLayoutItem)[]; + footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 +} | { + column: string; // 컬럼 이름 (footerUserSpans 사용 시) + footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 }; export type ColumnInfo = {