From e94782f56a4a3d11c051f31f640d2b6944908795 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Fri, 16 Jan 2026 11:54:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?st:=20feat:=20=EB=A9=80=ED=8B=B0=20footer,?= =?UTF-8?q?=20footer=20=EB=B3=91=ED=95=A9=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/components/module/DataGrid.tsx | 43 ++++++++++++++++--- .../review/SeweragePenetrationRateRvw.tsx | 5 ++- apps/st/src/components/type/DataGrid.type.ts | 11 +++++ .../SeweragePenetrationRateColumnRvw.ts | 21 +++++---- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 7221186be..2ca17b5c0 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -19,6 +19,8 @@ declare module "realgrid" { summaryMode?: 'aggregate' | 'statistical' | 'none'; checkBar?: { visible?: boolean }; }): void; + setFooters?(footers: Array<{ height?: number }>): void; + setColumnLayout?(layout: unknown[]): void; // footerUserSpans를 포함한 레이아웃 설정 } } @@ -270,7 +272,13 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport // footer가 있는 컬럼이 있으면 footer 높이 설정 const hasFooter = columns.some(col => col.footer); if (hasFooter) { - gridView.footer.height = 40; + // multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) + const hasMultiRowFooter = columns.some(col => col.footer && col.footer.multiRow); + if (hasMultiRowFooter) { + gridView.setFooters?.([{height: 40}, {height: 40}]); + } else { + gridView.footer.height = 40; + } } // 고정 컬럼 설정 @@ -284,6 +292,27 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport summaryMode: "aggregate" }); + // footerUserSpans가 있는 경우 setColumnLayout으로 설정 + const checkFooterUserSpans = (layout: unknown[]): boolean => { + return layout.some(item => { + if (typeof item === 'string') return false; + if (item && typeof item === 'object' && 'footerUserSpans' in item) return true; + if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) { + return checkFooterUserSpans(item.items); + } + return false; + }); + }; + + if (checkFooterUserSpans(columnLayout) && gridView.setColumnLayout) { + // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 + setTimeout(() => { + if (gridRef.current?.gridView && columnLayout) { + gridRef.current.gridView.setColumnLayout?.(columnLayout); + } + }, 100); + } + // footer의 valueCallback 설정 columns.forEach(col => { if (col.footer && col.footer.valueCallback) { @@ -320,12 +349,12 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }
- + {columns.map((column) => ( {
number | string; + multiRow?: boolean; // 여러 행 footer 사용 여부 }, }; +export type FooterUserSpan = { + rowspan?: number; // 병합할 행 수 + colspan?: number; // 병합할 컬럼 수 + text?: string; // 표시할 텍스트 +}; + export type ColumnLayoutItem = string | { name: string; hideChildHeaders?: boolean; header: { text: string }; items: (string | ColumnLayoutItem)[]; + footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 +} | { + column: string; // 컬럼 이름 (footerUserSpans 사용 시) + footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 }; export type ColumnInfo = { diff --git a/apps/st/src/components/util/column/review/SeweragePenetrationRateColumnRvw.ts b/apps/st/src/components/util/column/review/SeweragePenetrationRateColumnRvw.ts index fe7869a48..583a72aa2 100644 --- a/apps/st/src/components/util/column/review/SeweragePenetrationRateColumnRvw.ts +++ b/apps/st/src/components/util/column/review/SeweragePenetrationRateColumnRvw.ts @@ -1,9 +1,9 @@ import { ValueType } from "realgrid"; -import type { ColumnConfig } from "@rsone/components"; +import { footerInt, footerDecimal3 } from "@/components/util/grid/footerSummary"; export const SeweragePenetrationRateInfoList = { columns: [ - { name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true }, + { name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true, footer: { text: '합계', multiRow: true } }, { name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true }, { name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true }, { name: '수계', fieldName: '수계', header: { text: '수계' } }, @@ -12,12 +12,8 @@ export const SeweragePenetrationRateInfoList = { { name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' } }, { name: 'mdmbsn', fieldName: '소권역', header: { text: '소권역' } }, { name: 'specl_cntrpln_ar', fieldName: '특별대책지역', header: { text: '특별대책지역' } }, - { name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'popl_sm', fieldName: '내국인', header: { text: '내국인' } }, - { name: 'frgnr_popl_sm', fieldName: '외국인', header: { text: '외국인' } }, - { name: 'area', fieldName: '행정참고자료면적(㎢)', header: { text: '행정참고자료면적(㎢)' } }, - { name: 'popltn_sm', fieldName: '총인구(명)', header: { text: '총인구(명)' } }, - { name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, valueType: ValueType.NUMBER }, + { 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: '합계', fieldName: '합계', header: { text: '합계' } }, @@ -41,13 +37,16 @@ export const SeweragePenetrationRateInfoList = { { name: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급율(%)', header: { text: `고도처리인구보급율(%)` } }, { name: 'swrg_instlrt', fieldName: '하수도설치율(%)', header: { text: '하수도설치율(%)' } }, { name: 'rmk', fieldName: '비고', header: { text: '비고' } }, - ] as ColumnConfig[], + ], columnLayout: [ { name: 'groupA', hideChildHeaders: false, header: { text: '행정구역' }, - items: ['sido', 'sgg'], + items: [ + { column: 'sido', footerUserSpans: [{ colspan: 3 }] }, + 'sgg', + ] }, '구분', '수계', @@ -59,7 +58,7 @@ export const SeweragePenetrationRateInfoList = { items: ['mdbsn', 'lgbsn', 'mdmbsn'], }, 'specl_cntrpln_ar', - 'popltn_sm', + 'total_popl', 'ar_sm', { name: 'groupD', From 68c5548252b51c4091c676f7a8ced26c94d01999 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Thu, 29 Jan 2026 10:23:05 +0900 Subject: [PATCH 2/3] =?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 = { From 15dd319b21970677214869df04f6afbbd91afb9f Mon Sep 17 00:00:00 2001 From: rsone001 Date: Mon, 2 Feb 2026 16:49:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?st:=20fix:=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=85=80=20=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80,=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=EB=B3=84=20footer=20=EA=B3=84=EC=82=B0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80,=20footer=20=ED=8F=89=EA=B7=A0?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?,=20=EA=B0=81=EC=A2=85=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/module/CompactDataGrid.tsx | 38 ++- apps/st/src/components/module/DataGrid.tsx | 69 +++++- apps/st/src/components/module/SearchForm.tsx | 35 ++- apps/st/src/components/module/TabDataGrid.tsx | 56 ++++- .../input/SeweragePenetrationRateInp.tsx | 79 +++++- .../review/SeweragePenetrationRateRvw.tsx | 5 +- apps/st/src/components/type/DataGrid.type.ts | 48 ++++ .../st/src/components/type/SearchForm.type.ts | 6 +- .../input/SeweragePenetrationRateInpColumn.ts | 224 ++++++++++++------ .../SeweragePenetrationRateRvwColumn.ts | 12 +- .../util/grid/computedFieldCallbacks.ts | 99 ++++++++ .../src/components/util/grid/footerSummary.ts | 38 ++- .../api/statistics-input-service.ts | 23 ++ .../model/statistics-input-interface.ts | 118 +++++++++ .../statistics/model/statistics-interface.ts | 1 + apps/st/src/shared/config/api.ts | 3 +- .../rsone/components/grid/CompactDataGrid.tsx | 46 +++- packages/rsone/components/grid/DataGrid.tsx | 54 ++++- .../rsone/components/grid/TabDataGrid.tsx | 56 ++++- .../rsone/components/type/DataGrid.type.ts | 48 ++++ .../components/type/computedFieldCallbacks.ts | 99 ++++++++ 21 files changed, 1006 insertions(+), 151 deletions(-) create mode 100644 apps/st/src/components/util/grid/computedFieldCallbacks.ts create mode 100644 apps/st/src/entities/statistics/api/statistics-input-service.ts create mode 100644 apps/st/src/entities/statistics/model/statistics-input-interface.ts create mode 100644 packages/rsone/components/type/computedFieldCallbacks.ts diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index 5fdc0d7db..5eb83dfce 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -3,7 +3,8 @@ import { ValueType } from "realgrid" import RealGrid, { GridFitStyle } from "realgrid" import { RealGridReact, RGDataColumn } from "realgrid-react" -import type { DataGridProps } from "../type/DataGrid.type" +import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type" +import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" // RealGrid 타입 확장 declare module "realgrid" { @@ -24,6 +25,24 @@ interface RealGridDataProviderComparer { getValue?(row: number, field: string | number): unknown; } +/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { + return columns.map((col) => { + const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + fieldName: col.fieldName, + dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + }; + if (col.computed) { + const hasFields = "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + if (hasFields || hasNumeratorDenominator) { + field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + } + } + return field; + }); +} + /** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number { return (field, row1, row2) => { @@ -79,6 +98,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ checkBar: { visible: showHeaderCheckbox }, + rowIndicator: { visible: false }, }); // footer가 있는 컬럼이 있으면 footer 높이 설정 @@ -150,14 +170,15 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter = columns.map(col => { - const colInfo: any = { + const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { + const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, header: col.header, editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, + ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), }; if (col.footer) { @@ -176,7 +197,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, }); try { - (currentGridView as any).setColumns?.(columnsWithFooter); + (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -192,7 +213,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, if (footerItem.valueCallback && column.footer) { // footer가 배열인 경우 if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as any).valueCallback = footerItem.valueCallback; + (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; } } }); @@ -233,6 +254,12 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, autoGenerateField={true} layout={columnLayout} rows={rowData} + onInitialized={(grid) => { + const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + if (dp?.setFields) { + dp.setFields(buildFieldsFromColumns(columns)); + } + }} > {columns.map((column) => ( arr); } +/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { + return columns.map((col) => { + const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + fieldName: col.fieldName, + dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + }; + if (col.computed) { + const hasFields = "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + if (hasFields || hasNumeratorDenominator) { + field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + } + } + return field; + }); +} + const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -370,6 +390,21 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; + // 헤더 텍스트를 fieldName으로 매핑하는 맵 생성 + const headerToFieldNameMap = new Map(); + headers.forEach((headerText) => { + // columns에서 header.text가 일치하는 컬럼 찾기 + const matchedColumn = columns.find( + (col) => col.header?.text === headerText || col.fieldName === headerText + ); + if (matchedColumn?.fieldName) { + headerToFieldNameMap.set(headerText, matchedColumn.fieldName); + } else { + // 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성) + headerToFieldNameMap.set(headerText, headerText); + } + }); + // 데이터 행 읽기 const dataRows: Record[] = []; for (let row = dataStartRow; row <= range.e.r; row++) { @@ -380,6 +415,8 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const col = range.s.c + colIndex; const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })]; const headerName = headers[colIndex] || `Column${colIndex + 1}`; + // 헤더 텍스트를 fieldName으로 변환 + const fieldName = headerToFieldNameMap.get(headerName) || headerName; let cellValue: string | number = ''; if (cell && cell.v !== undefined && cell.v !== null) { @@ -402,7 +439,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport hasData = true; } - rowData[headerName] = cellValue; + rowData[fieldName] = cellValue; } // 데이터가 하나라도 있는 행만 추가 @@ -546,14 +583,15 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter = columns.map(col => { - const colInfo: any = { + const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { + const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, header: col.header, editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, + ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), }; if (col.footer) { @@ -572,7 +610,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }); try { - (currentGridView as any).setColumns?.(columnsWithFooter); + (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -588,7 +626,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport if (footerItem.valueCallback && column.footer) { // footer가 배열인 경우 if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as any).valueCallback = footerItem.valueCallback; + (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; } } }); @@ -653,6 +691,10 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport rows={rowData} onInitialized={(grid) => { (grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + if (dp?.setFields) { + dp.setFields(buildFieldsFromColumns(columns)); + } }} > {columns.map((column) => ( @@ -664,6 +706,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} header={column.header} editable={column.editable} + {...(column.visible === false && { visible: false })} footer={column.footer && !Array.isArray(column.footer) ? { text: column.footer.text, expression: column.footer.expression, diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index c0011fe5c..b2a470360 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -31,10 +31,17 @@ const getSggListFetcher = async (key: [string, string]): Promise { const { setMessage: alert } = useAlertStore((state) => state); const mergedProps = { ...defaultSearchFormProps, ...props }; + + const currentYear = new Date().getFullYear(); + const defaultYear = currentYear - 1; + const startYear = currentYear - 10; + + const yearOptions = Array.from({ length: currentYear - startYear + 1 }, (_, i) => currentYear - i); + const [values, setValues] = useState({ sido: props.defaultValues?.sido || "", - sgg: props.defaultValues?.sgg || "", - year: props.defaultValues?.year || "", + gugun: props.defaultValues?.gugun || "", + year: props.defaultValues?.year || String(defaultYear), order: props.defaultValues?.order || "", fctName: props.defaultValues?.fctName || "", }); @@ -83,6 +90,7 @@ const SearchForm = (props: SearchFormProps = {}) => { } ); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); props.onSubmit?.(values); @@ -91,14 +99,13 @@ const SearchForm = (props: SearchFormProps = {}) => { const handleReset = () => { const resetValues: SearchFormValues = { sido: "", - sgg: "", - year: "", + gugun: "", + year: String(defaultYear), order: "", fctName: "", }; setValues(resetValues); props.onReset?.(); - props.onSubmit?.(resetValues); }; return ( @@ -110,12 +117,12 @@ const SearchForm = (props: SearchFormProps = {}) => { - setValues((prev) => ({ ...prev, sido: value, gugun: "" }))}> - + - 전체 + 선택 {sidoList?.data?.map((sido) => ( {sido.sido} @@ -125,12 +132,12 @@ const SearchForm = (props: SearchFormProps = {}) => {
} - {mergedProps.sgg && + {mergedProps.gugun &&
- setValues({ ...values, gugun: value })}> @@ -152,10 +159,14 @@ const SearchForm = (props: SearchFormProps = {}) => {
diff --git a/apps/st/src/components/module/TabDataGrid.tsx b/apps/st/src/components/module/TabDataGrid.tsx index fd5b1749d..07dbaeef3 100644 --- a/apps/st/src/components/module/TabDataGrid.tsx +++ b/apps/st/src/components/module/TabDataGrid.tsx @@ -3,7 +3,8 @@ import { ValueType } from "realgrid" import RealGrid, { GridFitStyle } from "realgrid" import { RealGridReact, RGDataColumn } from "realgrid-react" -import type { TabDataGridProps, ColumnInfo } from "../type/DataGrid.type" +import type { TabDataGridProps, ColumnInfo, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type" +import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" import JSZip from "jszip" import * as XLSX from "xlsx" import { useAlertStore } from "@/shared/lib"; @@ -268,6 +269,24 @@ function sortRowsGroupPreserving( return entries.flatMap(([, arr]) => arr); } +/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { + return columns.map((col) => { + const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + fieldName: col.fieldName, + dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + }; + if (col.computed) { + const hasFields = "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + if (hasFields || hasNumeratorDenominator) { + field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + } + } + return field; + }); +} + /** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number { return (field, row1, row2) => { @@ -404,6 +423,22 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; + // 헤더 텍스트를 fieldName으로 매핑하는 맵 생성 + const headerToFieldNameMap = new Map(); + const columns = columnInfoItem.columns || []; + headers.forEach((headerText) => { + // columns에서 header.text가 일치하는 컬럼 찾기 + const matchedColumn = columns.find( + (col) => col.header?.text === headerText || col.fieldName === headerText + ); + if (matchedColumn?.fieldName) { + headerToFieldNameMap.set(headerText, matchedColumn.fieldName); + } else { + // 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성) + headerToFieldNameMap.set(headerText, headerText); + } + }); + // 데이터 행 읽기 const dataRows: Record[] = []; for (let row = dataStartRow; row <= range.e.r; row++) { @@ -414,6 +449,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const col = range.s.c + colIndex; const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })]; const headerName = headers[colIndex] || `Column${colIndex + 1}`; + // 헤더 텍스트를 fieldName으로 변환 + const fieldName = headerToFieldNameMap.get(headerName) || headerName; let cellValue: string | number = ''; if (cell && cell.v !== undefined && cell.v !== null) { @@ -436,7 +473,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r hasData = true; } - rowData[headerName] = cellValue; + rowData[fieldName] = cellValue; } // 데이터가 하나라도 있는 행만 추가 @@ -670,6 +707,11 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const columns = currentInfo.columns; + const dp = el.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + if (dp?.setFields) { + dp.setFields(buildFieldsFromColumns(columns)); + } + gridView.setDisplayOptions({ fitStyle: GridFitStyle.EVEN }); @@ -743,14 +785,15 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter = columns.map(col => { - const colInfo: any = { + const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { + const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, header: col.header, editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, + ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), }; if (col.footer) { @@ -769,7 +812,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }); try { - (currentGridView as any).setColumns?.(columnsWithFooter); + (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -785,7 +828,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r if (footerItem.valueCallback && column.footer) { // footer가 배열인 경우 if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as any).valueCallback = footerItem.valueCallback; + (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; } } }); @@ -897,6 +940,7 @@ 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} + {...(column.visible === false && { visible: false })} 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, diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 1f3c75eb7..795722c0f 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -1,16 +1,74 @@ import { useState } from "react"; import SearchForm from "@/components/module/SearchForm"; -import { DataGrid } from "@rsone/components"; +// import { DataGrid } from "@rsone/components"; +import DataGrid from "@/components/module/DataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; +import { LoadingWithAnimation } from "@common/components"; +import { useAlertStore } from "@/shared/lib"; +import { statisticsInputAPi } from "@/entities/statistics/api/statistics-input-service"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import type { SewerageSupplyRateItem } from "@/entities/statistics/model/statistics-input-interface"; import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn"; +// API 응답 항목을 DataGrid row 타입으로 변환 (null/undefined → 빈 문자열) +function toGridRows(items: SewerageSupplyRateItem[]): Record[] { + return items.map((item) => { + const row: Record = {}; + for (const [key, value] of Object.entries(item)) { + row[key] = value === null || value === undefined ? "" : value; + } + return row; + }); +} + // 하수도 통계 관리 > 01. 하수도 보급률 const SeweragePenetrationRateInp = () => { + const { setMessage: alert } = useAlertStore((state) => state); + const [rowData, setRowData] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + + const handleReset = () => { + setRowData([]); + }; + + const handleSearch = async (values: SearchFormValues) => { + try { + if (values.sido !== '' && values.sido !== 'ALL') { + setIsLoading(true); + + const response = await statisticsInputAPi.getSewerageSupplyRateList({ + sido: values.sido, + gugun: values.gugun === 'ALL' ? '' : values.gugun, + bsnmCd: '4139000000', + yr: values.year, + demoYr: values.year, + }); + + if (response.status === 'success' && response.data) { + const convertedData: SewerageSupplyRateItem[] = response.data.sewerageSupplyRateList.map((d) => ({ + ...d, + speclCntrplnAr: d.speclCntrplnAr === '' || d.speclCntrplnAr === null ? '해당없음' : d.speclCntrplnAr, + })); + + setRowData(toGridRows(convertedData)); + } else { + alert(response.message); + } + } else { + alert('시도를 선택해주세요.'); + } + } catch (error) { + alert('데이터 조회 중 오류가 발생했습니다.'); + setRowData([]); + } finally { + setIsLoading(false); + } + }; const SubmitExceptionFunc = () => { // 예외 제출 로직 @@ -28,11 +86,9 @@ const SeweragePenetrationRateInp = () => { // 저장 로직 }; - const [rowData, setRowData] = useState[]>([]); - return ( - <> - +
+
@@ -41,14 +97,19 @@ const SeweragePenetrationRateInp = () => {
- +
+ +
+
} + - +
); }; diff --git a/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx b/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx index 838298bd8..6e828cb4f 100644 --- a/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx +++ b/apps/st/src/components/statistics/review/SeweragePenetrationRateRvw.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { DataGrid } from "@rsone/components"; +// import { DataGrid } from "@rsone/components"; +import DataGrid from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; import SaveButton from "@/components/button/SaveButton"; import ApproveButton from "@/components/button/ApproveButton"; @@ -46,7 +47,7 @@ const SeweragePenetrationRateRvw = () => {
number | string; + } | 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; + }>; +}; + +/** setColumns 메서드가 있는 GridView */ +export type GridViewWithSetColumns = GridView & { setColumns?: (columns: RealGridColumnInfo[]) => void }; + +/** footer 배열 요소에 valueCallback 할당용 */ +export type FooterItemWithCallback = { valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string }; + export type GridView = { getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined; getDataSource(): { @@ -31,11 +59,31 @@ export type SortPreserveGroupOrder = { orderByField?: string; }; +/** RealGrid valueCallback 시그니처 (계산 필드용) */ +export type DataFieldValueCallback = ( + prod: unknown, + dataRow: number, + fieldName: string, + fieldNames: string[], + values: unknown[] +) => number | string | undefined; + +/** 계산 필드 설정 (computedFieldCallbacks.ts 참고) */ +export type ComputedFieldConfig = + | { fields: string[]; type: "sum" | "avg" } + | { numeratorFields: string[]; denominatorFields: string[]; type: "pblswrgPopltnSuprtRate" } + | { numeratorFields: string[]; denominatorFields: string[]; type: "adtrtPopltnSuprtRate" } + | { numeratorFields: string[]; denominatorFields: string[]; thirdFields: string[], type: "swrgInstlrtRate" }; + export type ColumnConfig = { name: string; fieldName: string; header: { text: string }; + /** 계산 필드: fields 합계/평균 등. valueCallback으로 dataProvider.setFields 시 전달 */ + computed?: ComputedFieldConfig; editable?: boolean; // 셀 편집 가능 여부 + /** 그리드에서 컬럼 숨김 (수식용 필드 등) */ + visible?: boolean; mergeCells?: boolean; sortdisable?: boolean; sortPreserveGroupOrder?: SortPreserveGroupOrder; diff --git a/apps/st/src/components/type/SearchForm.type.ts b/apps/st/src/components/type/SearchForm.type.ts index 50bd72a5d..157c57ec3 100644 --- a/apps/st/src/components/type/SearchForm.type.ts +++ b/apps/st/src/components/type/SearchForm.type.ts @@ -1,6 +1,6 @@ export type SearchFormValues = { sido?: string; - sgg?: string; + gugun?: string; year?: string; order?: string; fctName?: string; @@ -8,7 +8,7 @@ export type SearchFormValues = { export type SearchFormProps = { sido?: boolean; - sgg?: boolean; + gugun?: boolean; year?: boolean; order?: boolean; fctName?: boolean; @@ -20,7 +20,7 @@ export type SearchFormProps = { export const defaultSearchFormProps: Required> = { sido: true, - sgg: true, + gugun: true, year: true, order: false, fctName: false, diff --git a/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts b/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts index c89d3a8cd..50125939e 100644 --- a/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts +++ b/apps/st/src/components/util/column/input/SeweragePenetrationRateInpColumn.ts @@ -1,4 +1,4 @@ -import { footerInt, footerDecimal1, footerDecimal3 } from "@/components/util/grid/footerSummary"; +import { footerInt, footerDecimal1, footerDecimal3, footerAvgDecimal } from "@/components/util/grid/footerSummary"; import { ValueType } from "realgrid"; /** * 하수도 통계 관리 > 01. 하수도 보급률 @@ -7,74 +7,153 @@ import { ValueType } from "realgrid"; export const SeweragePenetrationRateInpColumn = { columns: [ // 행정구역 - { name: 'sido', fieldName: '시도', header: { text: '시도' }, editable: false }, - { name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, footer: { text: '합계' }, editable: false }, - + { name: 'sido', fieldName: 'sido', header: { text: '시도' }, footer: { text: '합계' }, editable: false }, + { name: 'gugun', fieldName: 'gugun', header: { text: '시군구' }, editable: false }, + { name: 'adstDong', fieldName: 'adstDong', header: { text: '행정구역명' }, editable: false }, + // 수계 및 권역 - { name: 'rvbsn', fieldName: '수계', header: { text: '수계' }, editable: false }, - { name: 'bstrm', fieldName: '지류', header: { text: '지류' }, editable: false }, + { name: 'rvbsn', fieldName: 'rvbsn', header: { text: '수계' }, editable: false }, + { name: 'bstrm', fieldName: 'bstrm', header: { text: '지류' }, editable: false }, // 수계영향권별분류 - { name: 'mdbsn', fieldName: '세부단위유역', header: { text: '세부단위유역' }, editable: false }, - { name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' }, editable: false }, - { name: 'mdmbsn', fieldName: '소권역', header: { text: '소권역' }, editable: false }, - + { name: 'mdbsn', fieldName: 'mdbsn', header: { text: '세부단위유역' }, editable: false }, + { name: 'lgbsn', fieldName: 'lgbsn', header: { text: '중권역' }, editable: false }, + { name: 'mdmbsn', fieldName: 'mdmbsn', header: { text: '소권역' }, editable: false }, + // 특별대책지역 - { name: 'specl_cntrpln_ar', fieldName: '특별대책지역', header: { text: '특별대책지역' } }, - + { name: 'speclCntrplnAr', fieldName: 'speclCntrplnAr', header: { text: '특별대책지역' } }, + // 행정참고자료인구 - { name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'popl_sm', fieldName: '내국인', header: { text: '내국인' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'frgnr_popl_sm', fieldName: '외국인', header: { text: '외국인' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - + { name: 'totalPopl', fieldName: 'totalPopl', header: { text: '총인구' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'poplSm', fieldName: 'poplSm', header: { text: '내국인' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'frgnrPoplSm', fieldName: 'frgnrPoplSm', header: { text: '외국인' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + // 행정참고자료면적 - { name: 'area', fieldName: '행정참고자료면적(㎢)', header: { text: '행정참고자료면적(㎢)' }, footer: footerDecimal3, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, - - // 총인구 (Nexacro: expr 존재) - { name: 'popltn_sm', fieldName: '총인구(명)', header: { text: '총인구(명)' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - + { name: 'area', fieldName: 'area', header: { text: '행정참고자료면적(㎢)' }, footer: footerDecimal3, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + + // 총인구 + { name: 'popltnSm', fieldName: 'popltnSm', 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: 'arSm', fieldName: 'arSm', header: { text: '총면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, // 하수처리구역 내 > 합계 - { name: 'swge_proc_zone_inner_sm', fieldName: '합계', header: { text: '합계' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { + name: 'swgeProcZoneInnerSm', + fieldName: 'swgeProcZoneInnerSm', + header: { text: '합계' }, + footer: footerInt, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0', + computed: { fields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt'], type: 'sum' as const } + }, // 하수처리구역 내 > 공공하수처리인구 - { name: 'pblswrg_popltn_sm', fieldName: '소계', header: { text: '소계' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'pblswrg_prm_trt', fieldName: '물리적처리', header: { text: '물리적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'pblswrg_scd_trt', fieldName: '생물학적처리', header: { text: '생물학적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'pblswrg_tert_trt', fieldName: '고도처리', header: { text: '고도처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - + { + name: 'pblswrgPopltnSm', + fieldName: 'pblswrgPopltnSm', + header: { text: '소계' }, + footer: footerInt, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0', + computed: { fields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt'], type: 'sum' as const } + }, + { name: 'pblswrgPrmTrt', fieldName: 'pblswrgPrmTrt', header: { text: '물리적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'pblswrgScdTrt', fieldName: 'pblswrgScdTrt', header: { text: '생물학적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'pblswrgTertTrt', fieldName: 'pblswrgTertTrt', header: { text: '고도처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + // 하수처리구역 내 > 폐수처리인구 - { name: 'wstr_popltn_sm', fieldName: '소계', header: { text: '소계' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'wstr_prm_trt', fieldName: '물리적처리', header: { text: '물리적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'wstr_scd_trt', fieldName: '생물학적처리', header: { text: '생물학적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'wstr_tert_trt', fieldName: '고도처리', header: { text: '고도처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - + { + name: 'wstrPopltnSm', + fieldName: 'wstrPopltnSm', + header: { text: '소계' }, + footer: footerInt, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0', + computed: { fields: ['wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt'], type: 'sum' as const } + }, + { name: 'wstrPrmTrt', fieldName: 'wstrPrmTrt', header: { text: '물리적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'wstrScdTrt', fieldName: 'wstrScdTrt', header: { text: '생물학적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'wstrTertTrt', fieldName: 'wstrTertTrt', header: { text: '고도처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + // 하수처리구역 내 > 미접속인구 - { name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - + { name: 'notConectPopltn', fieldName: 'notConectPopltn', header: { text: '미접속인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + // 하수처리구역 내 > 면적 - { name: 'swge_proc_zone_inner_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, - + { name: 'swgeProcZoneInnerAr', fieldName: 'swgeProcZoneInnerAr', header: { text: '면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + // 하수처리구역 외 - { name: 'swge_proc_zone_outer_sm', fieldName: '합계', header: { text: '합계' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'town_unproc_popltn', fieldName: '미처리인구', header: { text: '미처리인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, - { name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, - - // 공공하수처리구역 인구보급률 - { name: 'pblswrg_popltn_suprt', fieldName: '공공하수처리구역인구보급률(%)', header: { text: '공공하수처리구역인구보급률(%)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - - // 고도처리 인구보급률 - { name: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급률(%)', header: { text: '고도처리인구보급률(%)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - + { + name: 'swgeProcZoneOuterSm', + fieldName: 'swgeProcZoneOuterSm', + header: { text: '합계' }, + footer: footerInt, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0', + computed: { fields: ['townUnprocPopltn', 'townSwttPopltn', 'townWrrtnPopltn'], type: 'sum' as const } + }, + { name: 'townUnprocPopltn', fieldName: 'townUnprocPopltn', header: { text: '미처리인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'townSwttPopltn', fieldName: 'townSwttPopltn', header: { text: '오수처리인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'townWrrtnPopltn', fieldName: 'townWrrtnPopltn', header: { text: '정화조인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'swgeProcZoneOuterAr', fieldName: 'swgeProcZoneOuterAr', header: { text: '면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + + // 공공하수처리구역 인구보급률 (하수처리구역 내 인구 / 전체 인구 * 100) + { + name: 'pblswrgPopltnSuprt', + fieldName: 'pblswrgPopltnSuprt', + header: { text: '공공하수처리구역인구보급률(%)' }, + footer: footerAvgDecimal, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0.0', + computed: { + numeratorFields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt', 'notConectPopltn'], + denominatorFields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt', 'notConectPopltn', 'townUnprocPopltn', 'townSwttPopltn', 'townWrrtnPopltn'], + type: 'pblswrgPopltnSuprtRate' as const + } + }, + + // 고도처리 인구보급률 (고도처리 인구 / 전체 인구 * 100) + { + name: 'adtrtPopltnSuprt', + fieldName: 'adtrtPopltnSuprt', + header: { text: '고도처리인구보급률(%)' }, + footer: footerAvgDecimal, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0.0', + computed: { + numeratorFields: ['pblswrgTertTrt', 'wstrTertTrt'], + denominatorFields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt', 'notConectPopltn', 'townUnprocPopltn', 'townSwttPopltn', 'townWrrtnPopltn'], + type: 'adtrtPopltnSuprtRate' as const + } + }, + // 하수도 설치율 - { name: 'swrg_instlrt', fieldName: '하수도설치율(%)', header: { text: '하수도설치율(%)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, - + { + name: 'swrgInstlrt', + fieldName: 'swrgInstlrt', + header: { text: '하수도설치율(%)' }, + footer: footerAvgDecimal, + editable: false, + valueType: ValueType.NUMBER, + dataFormat: '#,##0.0', + computed: { + numeratorFields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt', 'notConectPopltn', 'swgeProcZoneOuterSm'], + denominatorFields: ['pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt', 'notConectPopltn'], + thirdFields: ['piplSupprt'], + type: 'swrgInstlrtRate' as const + } + }, + // 비고 - { name: 'rmk', fieldName: '비고', header: { text: '비고' } }, + { name: 'rmk', fieldName: 'rmk', header: { text: '비고' } }, + + { name: 'piplSupprt', fieldName: 'piplSupprt', header: { text: '하수관로보급률(%)' }, visible: false }, ], columnLayout: [ @@ -82,7 +161,11 @@ export const SeweragePenetrationRateInpColumn = { name: 'groupA', hideChildHeaders: false, header: { text: '행정구역' }, - items: ['sido', 'sgg'], + items: [ + { column: 'sido', footerUserSpans: [{ colspan: 3 }] }, + 'gugun', + 'adstDong', + ], }, 'rvbsn', 'bstrm', @@ -92,36 +175,36 @@ export const SeweragePenetrationRateInpColumn = { header: { text: '수계영향권별분류' }, items: ['mdbsn', 'lgbsn', 'mdmbsn'], }, - 'specl_cntrpln_ar', + 'speclCntrplnAr', { name: 'groupC', hideChildHeaders: false, header: { text: '행정참고자료인구' }, - items: ['total_popl', 'popl_sm', 'frgnr_popl_sm'], + items: ['totalPopl', 'poplSm', 'frgnrPoplSm'], }, 'area', - 'popltn_sm', - 'ar_sm', + 'popltnSm', + 'arSm', { name: 'groupD', hideChildHeaders: false, header: { text: '하수처리구역내' }, items: [ - 'swge_proc_zone_inner_sm', + 'swgeProcZoneInnerSm', { name: 'pblswrgGroup', hideChildHeaders: false, header: { text: '공공하수처리인구(명)' }, - items: ['pblswrg_popltn_sm', 'pblswrg_prm_trt', 'pblswrg_scd_trt', 'pblswrg_tert_trt'], + items: ['pblswrgPopltnSm', 'pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt'], }, { name: 'wstrGroup', hideChildHeaders: false, header: { text: '폐수처리인구(명)' }, - items: ['wstr_popltn_sm', 'wstr_prm_trt', 'wstr_scd_trt', 'wstr_tert_trt'], + items: ['wstrPopltnSm', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt'], }, - 'not_conect_popltn', - 'swge_proc_zone_inner_ar' + 'notConectPopltn', + 'swgeProcZoneInnerAr' ], }, { @@ -129,16 +212,17 @@ export const SeweragePenetrationRateInpColumn = { hideChildHeaders: false, header: { text: '하수처리구역외' }, items: [ - 'swge_proc_zone_outer_sm', - 'town_unproc_popltn', - 'town_swtt_popltn', - 'town_wrrtn_popltn', - 'swge_proc_zone_outer_ar' + 'swgeProcZoneOuterSm', + 'townUnprocPopltn', + 'townSwttPopltn', + 'townWrrtnPopltn', + 'swgeProcZoneOuterAr' ], }, - 'pblswrg_popltn_suprt', - 'adtrt_popltn_suprt', - 'swrg_instlrt', + 'pblswrgPopltnSuprt', + 'adtrtPopltnSuprt', + 'swrgInstlrt', 'rmk', + 'piplSupprt', ] }; \ No newline at end of file diff --git a/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts b/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts index 49e962de2..968c2732f 100644 --- a/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts +++ b/apps/st/src/components/util/column/review/SeweragePenetrationRateRvwColumn.ts @@ -1,5 +1,5 @@ import { ValueType } from "realgrid"; -import { footerInt, footerDecimal3, footerIntArrayByDivision, footerDecimal3ArrayByDivision } from "@/components/util/grid/footerSummary"; +import { footerIntArrayByDivision, footerDecimal3ArrayByDivision } from "@/components/util/grid/footerSummary"; import type { GridView, DataColumn, ColumnFooter } from "@/components/type/DataGrid.type"; // 하수도 통계 관리 01. 하수도 보급률 @@ -101,10 +101,8 @@ export const SeweragePenetrationRateInfoList = { } 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 ''; @@ -132,10 +130,8 @@ export const SeweragePenetrationRateInfoList = { } 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 ''; @@ -164,12 +160,12 @@ export const SeweragePenetrationRateInfoList = { { name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, { 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: 'swge_proc_zone_inner_ar', fieldName: 'swge_proc_zone_inner_ar', header: { text: '면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' }, + { name: 'swge_proc_zone_outer_sm', fieldName: 'swge_proc_zone_outer_sm', 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: 'swge_proc_zone_outer_ar', fieldName: 'swge_proc_zone_outer_ar', 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: '하수도설치율(%)' } }, diff --git a/apps/st/src/components/util/grid/computedFieldCallbacks.ts b/apps/st/src/components/util/grid/computedFieldCallbacks.ts new file mode 100644 index 000000000..a19b9f7ea --- /dev/null +++ b/apps/st/src/components/util/grid/computedFieldCallbacks.ts @@ -0,0 +1,99 @@ +import type { DataFieldValueCallback, ComputedFieldConfig } from "@/components/type/DataGrid.type"; + +/** 값 → 숫자 변환 (null/undefined/빈문자열 → 0) */ +function toNum(v: unknown): number { + if (v == null || v === "") return 0; + if (typeof v === "number") return isNaN(v) ? 0 : v; + const n = parseFloat(String(v).replace(/,/g, "")); + return isNaN(n) ? 0 : n; +} + +/** 합계용 valueCallback 생성 */ +export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + let sum = 0; + for (const f of fieldNames) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) sum += toNum(values[idx]); + } + return sum; + }; +} + +/** 평균용 valueCallback 생성 */ +export function createAvgValueCallback(fieldNames: string[]): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + let sum = 0; + let count = 0; + for (const f of fieldNames) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) { + const val = toNum(values[idx]); + sum += val; + count += 1; + } + } + return count > 0 ? sum / count : 0; + }; +} + +// 공공하수처리구역인구보급률(%), 고도처리인구보급률(%) valueCallback +export function createCoverageRateValueCallback( + numeratorFields: string[], + denominatorFields: string[] +): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + const getSum = (fields: string[]) => { + let sum = 0; + for (const f of fields) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) sum += toNum(values[idx]); + } + return sum; + }; + const total2 = getSum(numeratorFields); + const total1 = getSum(denominatorFields); + return total1 === 0 ? 0 : (total2 / total1) * 100; + }; +} + +// 하수도 설치율(%) valueCallback +export function createSwrgInstlrtRateValueCallback( + numeratorFields: string[], + denominatorFields: string[], + thirdFields: string[], +): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + const getSum = (fields: string[]) => { + let sum = 0; + for (const f of fields) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) sum += toNum(values[idx]); + } + return sum; + }; + const sum1 = getSum(numeratorFields); + const sum2 = getSum(denominatorFields); + + const piplSupprt = toNum(values[fieldNamesArr.indexOf(thirdFields[0] || 'piplSupprt')]); + + const total1 = (sum1 === 0 ? 0 : Math.round(sum2 / sum1 * 100)); + + return total1 * piplSupprt / 100; + }; +} + +/** 연산 타입에 맞는 valueCallback 반환 */ +export function getComputedValueCallback(config: ComputedFieldConfig): DataFieldValueCallback { + switch (config.type) { + case 'sum': + return createSumValueCallback(config.fields); + case 'avg': + return createAvgValueCallback(config.fields); + case 'pblswrgPopltnSuprtRate': + case 'adtrtPopltnSuprtRate': + return createCoverageRateValueCallback(config.numeratorFields, config.denominatorFields); + case 'swrgInstlrtRate': + return createSwrgInstlrtRateValueCallback(config.numeratorFields, config.denominatorFields, config.thirdFields); + } +} diff --git a/apps/st/src/components/util/grid/footerSummary.ts b/apps/st/src/components/util/grid/footerSummary.ts index 78cc0b601..6364454eb 100644 --- a/apps/st/src/components/util/grid/footerSummary.ts +++ b/apps/st/src/components/util/grid/footerSummary.ts @@ -23,6 +23,31 @@ export const getFooterSummary = (grid: GridView, column: DataColumn) => { return sum; }; +// 평균 계산 함수 +export const getFooterAvgSummary = (grid: GridView, column: DataColumn) => { + const dataSource = grid.getDataSource(); + const rowCount = dataSource.getRowCount(); + + let sum = 0; + let avg = 0; + + for (let i = 0; i < rowCount; i++) { + 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; + } + } + + avg = sum / rowCount; + + return Math.floor(avg * 10) / 10; +} + // "구분" 필드의 고유값들을 찾는 함수 (첫 번째, 두 번째) const getDivisionValues = (grid: GridView, divisionFieldName: string = '구분'): string[] => { const dataSource = grid.getDataSource(); @@ -91,6 +116,14 @@ const createSumFooter = (format: string) => ({ }, }); +const createAvgFooter = (format: string) => ({ + expression: 'avg' as const, + numberFormat: format, + valueCallback: (grid: GridView, column: DataColumn) => { + return getFooterAvgSummary(grid, column); + }, +}); + // "구분" 값에 따라 필터링하는 Footer 배열 생성 함수 (2줄) const createSumFooterArrayByDivision = (format: string) => [ { @@ -139,4 +172,7 @@ export const footerDecimal3 = createSumFooter('#,##0.000'); export const footerIntArrayByDivision = createSumFooterArrayByDivision('#,##0'); // "구분" 값에 따라 필터링하는 Footer 배열 (소수점 3자리, 2줄) -export const footerDecimal3ArrayByDivision = createSumFooterArrayByDivision('#,##0.000'); \ No newline at end of file +export const footerDecimal3ArrayByDivision = createSumFooterArrayByDivision('#,##0.000'); + +// 률(%) 평균 값(기본 소수점 1자리) +export const footerAvgDecimal = createAvgFooter('#,##0.0'); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-input-service.ts b/apps/st/src/entities/statistics/api/statistics-input-service.ts new file mode 100644 index 000000000..ca5d4e102 --- /dev/null +++ b/apps/st/src/entities/statistics/api/statistics-input-service.ts @@ -0,0 +1,23 @@ +import axios from "axios"; +import { ENDPOINT } from "@/shared/config"; + +import { + type StatisticsInputService, + type SewerageSupplyRateRequestParams, + type SewerageSupplyRateApiResponse, +} from "../model/statistics-input-interface"; + +export class StatisticsInputAPiService implements StatisticsInputService { + public async getSewerageSupplyRateList(params: SewerageSupplyRateRequestParams): Promise { + const response = await axios.get ( + `${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate`, + { + params: params + } + ); + + return response.data; + } +} + +export const statisticsInputAPi = new StatisticsInputAPiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/statistics-input-interface.ts b/apps/st/src/entities/statistics/model/statistics-input-interface.ts new file mode 100644 index 000000000..111216424 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-input-interface.ts @@ -0,0 +1,118 @@ +import type { ApiResponse } from "./statistics-interface"; + +// 하수도 보급률 API 응답의 상태 정보 +export interface SewerageSupplyRateStatusInfo { + locgStatus: string; + locgDt: string; + locgCharger: string; + locgCttpc: string; + prvnStatus: string; + dcsnStatus: string; + wrterOpinionSm: string | null; + chckerOpinionSm: string | null; + returnOpinionSm: string | null; +} + +// 하수도 보급률 데이터 항목 +export interface SewerageSupplyRateItem { + sido: string; + gugun: string; + adstDong: string; + rgnNm: string; + adstCd: string; + yr: string; + bsnmCd: string; + rvbsn: string; + lgbsn: string; + mdbsn: string; + mdmbsn: string; + totalPopl: number; + poplSm: number; + frgnrPoplSm: number; + area: number; + bsnmTotalPopl: number; + bsnmPoplSm: number; + bsnmFrgnrPoplSm: number; + bsnmArea: number; + piplPlanExtn: number; + piplFcltyExtn: number; + piplSupprt: number; + wrssm: string | null; + bstrm: string; + speclCntrplnAr: string | null; + popltnSm: number; + arSm: number; + swgeProcZoneInnerSm: number; + pblswrgPopltnSm: number; + pblswrgPrmTrt: number; + pblswrgScdTrt: number; + pblswrgTertTrt: number; + wstrPopltnSm: number; + wstrPrmTrt: number; + wstrScdTrt: number; + wstrTertTrt: number; + swgeProcZoneInnerAr: number; + swgeProcZoneOuterSm: number; + townUnprocPopltn: number; + townSwttPopltn: number; + townWrrtnPopltn: number; + uptownUnprPopltn: number; + uptownSwttPopltn: number; + uptownWrrtnPopltn: number; + swgeProcZoneOuterAr: number; + pblswrgPopltnSuprt: number; + adtrtPopltnSuprt: number; + swrgInstlrt: number; + wrterOpinion: string | null; + chckerOpinion: string | null; + notConectPopltn: number; + rmk: string; + chkWrssm: string; + chkBstrm: string; + chkSpeclCntrplnAr: string; + chkPopltnSm: string; + chkArSm: string; + chkSwgeProcZoneInnerSm: string; + chkPblswrgPopltnSm: string; + chkPblswrgPrmTrt: string; + chkPblswrgScdTrt: string; + chkPblswrgTertTrt: string; + chkWstrPopltnSm: string; + chkWstrPrmTrt: string; + chkWstrScdTrt: string; + chkWstrTertTrt: string; + chkSwgeProcZoneInnerAr: string; + chkSwgeProcZoneOuterSm: string; + chkTownUnprocPopltn: string; + chkTownSwttPopltn: string; + chkTownWrrtnPopltn: string; + chkUptownUnprPopltn: string; + chkUptownSwttPopltn: string; + chkUptownWrrtnPopltn: string; + chkSwgeProcZoneOuterAr: string; + chkPblswrgPopltnSuprt: string; + chkAdtrtPopltnSuprt: string; + chkSwrgInstlrt: string; + chkNotConectPopltn: string; +} + +export interface SewerageSupplyRateResponseData { + statusInfo: SewerageSupplyRateStatusInfo; + sewerageSupplyRateList: SewerageSupplyRateItem[]; +} + +export type SewerageSupplyRateApiResponse = ApiResponse & { + message: string; +}; + +export interface SewerageSupplyRateRequestParams { + sido?: string; + gugun?: string; + bsnmCd?: string; + yr?: string; + demoYr?: string; +} + +export interface StatisticsInputService { + getSewerageSupplyRateList(params: SewerageSupplyRateRequestParams): Promise; +} \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index d687df5c2..ee956bdd0 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -11,6 +11,7 @@ export interface Sgg { export interface ApiResponse { status: string; data: T; + message?: string; } export interface StatisticsService { diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 8325c0e32..90e5a6cba 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -65,7 +65,8 @@ export const ENDPOINT = { POST_EVALUATION: "/board-service/api/v1/pstEvl", }, ST_SERVICE: { - COMMON: "/st-management-service/api/v1/common", + COMMON: "/st-input-service/api/v1/common", + INPUT: "/st-input-service/api/v1/input", } }; diff --git a/packages/rsone/components/grid/CompactDataGrid.tsx b/packages/rsone/components/grid/CompactDataGrid.tsx index 5fdc0d7db..837a5daac 100644 --- a/packages/rsone/components/grid/CompactDataGrid.tsx +++ b/packages/rsone/components/grid/CompactDataGrid.tsx @@ -1,9 +1,10 @@ -import { useRef, useEffect } from "react" -import { ValueType } from "realgrid" +import { useRef, useEffect } from "react"; +import { ValueType } from "realgrid"; -import RealGrid, { GridFitStyle } from "realgrid" -import { RealGridReact, RGDataColumn } from "realgrid-react" -import type { DataGridProps } from "../type/DataGrid.type" +import RealGrid, { GridFitStyle } from "realgrid"; +import { RealGridReact, RGDataColumn } from "realgrid-react"; +import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type"; +import { getComputedValueCallback } from "@/type/computedFieldCallbacks"; // RealGrid 타입 확장 declare module "realgrid" { @@ -24,6 +25,24 @@ interface RealGridDataProviderComparer { getValue?(row: number, field: string | number): unknown; } +/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { + return columns.map((col) => { + const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + fieldName: col.fieldName, + dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + }; + if (col.computed) { + const hasFields = "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + if (hasFields || hasNumeratorDenominator) { + field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + } + } + return field; + }); +} + /** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number { return (field, row1, row2) => { @@ -79,6 +98,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ checkBar: { visible: showHeaderCheckbox }, + rowIndicator: { visible: false }, }); // footer가 있는 컬럼이 있으면 footer 높이 설정 @@ -150,14 +170,15 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter = columns.map(col => { - const colInfo: any = { + const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { + const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, header: col.header, editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, + ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), }; if (col.footer) { @@ -176,7 +197,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, }); try { - (currentGridView as any).setColumns?.(columnsWithFooter); + (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -192,7 +213,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, if (footerItem.valueCallback && column.footer) { // footer가 배열인 경우 if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as any).valueCallback = footerItem.valueCallback; + (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; } } }); @@ -233,6 +254,12 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, autoGenerateField={true} layout={columnLayout} rows={rowData} + onInitialized={(grid) => { + const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + if (dp?.setFields) { + dp.setFields(buildFieldsFromColumns(columns)); + } + }} > {columns.map((column) => ( arr); } +/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { + return columns.map((col) => { + const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + fieldName: col.fieldName, + dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + }; + if (col.computed) { + const hasFields = "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + if (hasFields || hasNumeratorDenominator) { + field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + } + } + return field; + }); +} + const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -371,6 +390,21 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; + // 헤더 텍스트를 fieldName으로 매핑하는 맵 생성 + const headerToFieldNameMap = new Map(); + headers.forEach((headerText) => { + // columns에서 header.text가 일치하는 컬럼 찾기 + const matchedColumn = columns.find( + (col) => col.header?.text === headerText || col.fieldName === headerText + ); + if (matchedColumn?.fieldName) { + headerToFieldNameMap.set(headerText, matchedColumn.fieldName); + } else { + // 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성) + headerToFieldNameMap.set(headerText, headerText); + } + }); + // 데이터 행 읽기 const dataRows: Record[] = []; for (let row = dataStartRow; row <= range.e.r; row++) { @@ -381,6 +415,8 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const col = range.s.c + colIndex; const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })]; const headerName = headers[colIndex] || `Column${colIndex + 1}`; + // 헤더 텍스트를 fieldName으로 변환 + const fieldName = headerToFieldNameMap.get(headerName) || headerName; let cellValue: string | number = ''; if (cell && cell.v !== undefined && cell.v !== null) { @@ -403,7 +439,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport hasData = true; } - rowData[headerName] = cellValue; + rowData[fieldName] = cellValue; } // 데이터가 하나라도 있는 행만 추가 @@ -547,14 +583,15 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter = columns.map(col => { - const colInfo: any = { + const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { + const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, header: col.header, editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, + ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), }; if (col.footer) { @@ -573,7 +610,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport }); try { - (currentGridView as any).setColumns?.(columnsWithFooter); + (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -589,7 +626,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport if (footerItem.valueCallback && column.footer) { // footer가 배열인 경우 if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as any).valueCallback = footerItem.valueCallback; + (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; } } }); @@ -654,6 +691,10 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport rows={rowData} onInitialized={(grid) => { (grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef); + const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + if (dp?.setFields) { + dp.setFields(buildFieldsFromColumns(columns)); + } }} > {columns.map((column) => ( @@ -665,6 +706,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} header={column.header} editable={column.editable} + {...(column.visible === false && { visible: false })} footer={column.footer && !Array.isArray(column.footer) ? { text: column.footer.text, expression: column.footer.expression, diff --git a/packages/rsone/components/grid/TabDataGrid.tsx b/packages/rsone/components/grid/TabDataGrid.tsx index 68d199404..ea618e8ac 100644 --- a/packages/rsone/components/grid/TabDataGrid.tsx +++ b/packages/rsone/components/grid/TabDataGrid.tsx @@ -3,7 +3,8 @@ import { ValueType } from "realgrid" import RealGrid, { GridFitStyle } from "realgrid" import { RealGridReact, RGDataColumn } from "realgrid-react" -import type { TabDataGridProps, ColumnInfo } from "../type/DataGrid.type" +import type { TabDataGridProps, ColumnInfo, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type" +import { getComputedValueCallback } from "@/type/computedFieldCallbacks" import JSZip from "jszip" import * as XLSX from "xlsx" import { useAlertStore } from "../store/alert-store"; @@ -268,6 +269,24 @@ function sortRowsGroupPreserving( return entries.flatMap(([, arr]) => arr); } +/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */ +function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) { + return columns.map((col) => { + const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = { + fieldName: col.fieldName, + dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + }; + if (col.computed) { + const hasFields = "fields" in col.computed && col.computed.fields.length > 0; + const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0; + if (hasFields || hasNumeratorDenominator) { + field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number; + } + } + return field; + }); +} + /** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number { return (field, row1, row2) => { @@ -404,6 +423,22 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow; const dataStartRow = maxHeaderRowIndex + 1; + // 헤더 텍스트를 fieldName으로 매핑하는 맵 생성 + const headerToFieldNameMap = new Map(); + const columns = columnInfoItem.columns || []; + headers.forEach((headerText) => { + // columns에서 header.text가 일치하는 컬럼 찾기 + const matchedColumn = columns.find( + (col) => col.header?.text === headerText || col.fieldName === headerText + ); + if (matchedColumn?.fieldName) { + headerToFieldNameMap.set(headerText, matchedColumn.fieldName); + } else { + // 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성) + headerToFieldNameMap.set(headerText, headerText); + } + }); + // 데이터 행 읽기 const dataRows: Record[] = []; for (let row = dataStartRow; row <= range.e.r; row++) { @@ -414,6 +449,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const col = range.s.c + colIndex; const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })]; const headerName = headers[colIndex] || `Column${colIndex + 1}`; + // 헤더 텍스트를 fieldName으로 변환 + const fieldName = headerToFieldNameMap.get(headerName) || headerName; let cellValue: string | number = ''; if (cell && cell.v !== undefined && cell.v !== null) { @@ -436,7 +473,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r hasData = true; } - rowData[headerName] = cellValue; + rowData[fieldName] = cellValue; } // 데이터가 하나라도 있는 행만 추가 @@ -670,6 +707,11 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const columns = currentInfo.columns; + const dp = el.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined; + if (dp?.setFields) { + dp.setFields(buildFieldsFromColumns(columns)); + } + gridView.setDisplayOptions({ fitStyle: GridFitStyle.EVEN }); @@ -743,14 +785,15 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer)); if (hasArrayFooter) { // 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출 - const columnsWithFooter = columns.map(col => { - const colInfo: any = { + const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => { + const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, header: col.header, editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, + ...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }), }; if (col.footer) { @@ -769,7 +812,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r }); try { - (currentGridView as any).setColumns?.(columnsWithFooter); + (currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -785,7 +828,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r if (footerItem.valueCallback && column.footer) { // footer가 배열인 경우 if (Array.isArray(column.footer) && column.footer[index]) { - (column.footer[index] as any).valueCallback = footerItem.valueCallback; + (column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback; } } }); @@ -897,6 +940,7 @@ 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} + {...(column.visible === false && { visible: false })} 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, diff --git a/packages/rsone/components/type/DataGrid.type.ts b/packages/rsone/components/type/DataGrid.type.ts index 9f9e16a70..2574b4d1b 100644 --- a/packages/rsone/components/type/DataGrid.type.ts +++ b/packages/rsone/components/type/DataGrid.type.ts @@ -2,6 +2,34 @@ import React from "react"; import { ValueType } from "realgrid"; // RealGrid 타입 정의 +/** setColumns에 전달하는 컬럼 정보 (footer 배열/단일 객체 포함) */ +export type RealGridColumnInfo = { + name: string; + fieldName: string; + header: { text: string }; + editable?: boolean; + valueType?: import("realgrid").ValueType; + dataFormat?: string; + mergeRule?: { criteria: 'value' }; + footer?: { + 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; + } | 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; + }>; +}; + +/** setColumns 메서드가 있는 GridView */ +export type GridViewWithSetColumns = GridView & { setColumns?: (columns: RealGridColumnInfo[]) => void }; + +/** footer 배열 요소에 valueCallback 할당용 */ +export type FooterItemWithCallback = { valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string }; + export type GridView = { getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined; getDataSource(): { @@ -31,11 +59,31 @@ export type SortPreserveGroupOrder = { orderByField?: string; }; +/** RealGrid valueCallback 시그니처 (계산 필드용) */ +export type DataFieldValueCallback = ( + prod: unknown, + dataRow: number, + fieldName: string, + fieldNames: string[], + values: unknown[] +) => number | string | undefined; + +/** 계산 필드 설정 (computedFieldCallbacks.ts 참고) */ +export type ComputedFieldConfig = + | { fields: string[]; type: "sum" | "avg" } + | { numeratorFields: string[]; denominatorFields: string[]; type: "pblswrgPopltnSuprtRate" } + | { numeratorFields: string[]; denominatorFields: string[]; type: "adtrtPopltnSuprtRate" } + | { numeratorFields: string[]; denominatorFields: string[]; thirdFields: string[], type: "swrgInstlrtRate" }; + export type ColumnConfig = { name: string; fieldName: string; header: { text: string }; + /** 계산 필드: fields 합계/평균 등. valueCallback으로 dataProvider.setFields 시 전달 */ + computed?: ComputedFieldConfig; editable?: boolean; // 셀 편집 가능 여부 + /** 그리드에서 컬럼 숨김 (수식용 필드 등) */ + visible?: boolean; mergeCells?: boolean; sortdisable?: boolean; sortPreserveGroupOrder?: SortPreserveGroupOrder; diff --git a/packages/rsone/components/type/computedFieldCallbacks.ts b/packages/rsone/components/type/computedFieldCallbacks.ts new file mode 100644 index 000000000..362d0da7f --- /dev/null +++ b/packages/rsone/components/type/computedFieldCallbacks.ts @@ -0,0 +1,99 @@ +import type { DataFieldValueCallback, ComputedFieldConfig } from './DataGrid.type' + +/** 값 → 숫자 변환 (null/undefined/빈문자열 → 0) */ +function toNum(v: unknown): number { + if (v == null || v === "") return 0; + if (typeof v === "number") return isNaN(v) ? 0 : v; + const n = parseFloat(String(v).replace(/,/g, "")); + return isNaN(n) ? 0 : n; +} + +/** 합계용 valueCallback 생성 */ +export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + let sum = 0; + for (const f of fieldNames) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) sum += toNum(values[idx]); + } + return sum; + }; +} + +/** 평균용 valueCallback 생성 */ +export function createAvgValueCallback(fieldNames: string[]): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + let sum = 0; + let count = 0; + for (const f of fieldNames) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) { + const val = toNum(values[idx]); + sum += val; + count += 1; + } + } + return count > 0 ? sum / count : 0; + }; +} + +// 공공하수처리구역인구보급률(%), 고도처리인구보급률(%) valueCallback +export function createCoverageRateValueCallback( + numeratorFields: string[], + denominatorFields: string[] +): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + const getSum = (fields: string[]) => { + let sum = 0; + for (const f of fields) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) sum += toNum(values[idx]); + } + return sum; + }; + const total2 = getSum(numeratorFields); + const total1 = getSum(denominatorFields); + return total1 === 0 ? 0 : (total2 / total1) * 100; + }; +} + +// 하수도 설치율(%) valueCallback +export function createSwrgInstlrtRateValueCallback( + numeratorFields: string[], + denominatorFields: string[], + thirdFields: string[], +): DataFieldValueCallback { + return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { + const getSum = (fields: string[]) => { + let sum = 0; + for (const f of fields) { + const idx = fieldNamesArr.indexOf(f); + if (idx >= 0) sum += toNum(values[idx]); + } + return sum; + }; + const sum1 = getSum(numeratorFields); + const sum2 = getSum(denominatorFields); + + const piplSupprt = toNum(values[fieldNamesArr.indexOf(thirdFields[0] || 'piplSupprt')]); + + const total1 = (sum1 === 0 ? 0 : Math.round(sum2 / sum1 * 100)); + + return total1 * piplSupprt / 100; + }; +} + +/** 연산 타입에 맞는 valueCallback 반환 */ +export function getComputedValueCallback(config: ComputedFieldConfig): DataFieldValueCallback { + switch (config.type) { + case 'sum': + return createSumValueCallback(config.fields); + case 'avg': + return createAvgValueCallback(config.fields); + case 'pblswrgPopltnSuprtRate': + case 'adtrtPopltnSuprtRate': + return createCoverageRateValueCallback(config.numeratorFields, config.denominatorFields); + case 'swrgInstlrtRate': + return createSwrgInstlrtRateValueCallback(config.numeratorFields, config.denominatorFields, config.thirdFields); + } +}