From 15dd319b21970677214869df04f6afbbd91afb9f Mon Sep 17 00:00:00 2001 From: rsone001 Date: Mon, 2 Feb 2026 16:49:10 +0900 Subject: [PATCH] =?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); + } +}