st: fix: 그리드 셀 편집 시 계산 로직 추가, 구분 별 footer 계산 로직 추가, footer 평균 계산 함수 추가, 각종 오류 수정

This commit is contained in:
2026-02-02 16:49:10 +09:00
parent 68c5548252
commit 15dd319b21
21 changed files with 1006 additions and 151 deletions
@@ -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) => (
<RGDataColumn
@@ -243,6 +270,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
{...(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,
+56 -13
View File
@@ -1,15 +1,17 @@
import { useRef, useEffect } from "react"
import { ValueType } from "realgrid"
import { useRef, useEffect } from "react";
import { ValueType } from "realgrid";
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid";
import { RealGridReact, RGDataColumn } from "realgrid-react";
import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type";
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks";
import JSZip from "jszip";
import * as XLSX from "xlsx";
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"
import { RealGridReact, RGDataColumn } from "realgrid-react"
import type { DataGridProps } from "../type/DataGrid.type"
import JSZip from "jszip"
import * as XLSX from "xlsx"
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
import { useTabStore } from "@/store/tab-store"
import { useTabStore } from "@/store/tab-store";
// RealGrid 타입 확장
declare module "realgrid" {
@@ -269,6 +271,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;
});
}
const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(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<string, string>();
headers.forEach((headerText) => {
// columns에서 header.text가 일치하는 컬럼 찾기
const matchedColumn = columns.find(
(col) => col.header?.text === headerText || col.fieldName === headerText
);
if (matchedColumn?.fieldName) {
headerToFieldNameMap.set(headerText, matchedColumn.fieldName);
} else {
// 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성)
headerToFieldNameMap.set(headerText, headerText);
}
});
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
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,
+23 -12
View File
@@ -31,10 +31,17 @@ const getSggListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg
const SearchForm = (props: SearchFormProps = {}) => {
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<SearchFormValues>({
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<HTMLFormElement>) => {
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 = {}) => {
<Label className="text-label">
시도
</Label>
<Select value={values.sido} onValueChange={(value) => setValues((prev) => ({ ...prev, sido: value, sgg: 'ALL' }))}>
<Select value={values.sido} onValueChange={(value) => setValues((prev) => ({ ...prev, sido: value, gugun: "" }))}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체"></SelectValue>
<SelectValue placeholder="선택"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="ALL">선택</SelectItem>
{sidoList?.data?.map((sido) => (
<SelectItem key={sido.adstDstrCd} value={sido.sido}>
{sido.sido}
@@ -125,12 +132,12 @@ const SearchForm = (props: SearchFormProps = {}) => {
</Select>
</div>
}
{mergedProps.sgg &&
{mergedProps.gugun &&
<div className="flex items-center gap-2">
<Label className="text-label">
시군구
</Label>
<Select value={values.sgg} onValueChange={(value) => setValues({ ...values, sgg: value })}>
<Select value={values.gugun} onValueChange={(value) => setValues({ ...values, gugun: value })}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
@@ -152,10 +159,14 @@ const SearchForm = (props: SearchFormProps = {}) => {
</Label>
<Select value={values.year} onValueChange={(value) => setValues({ ...values, year: value })}>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
<SelectValue placeholder="선택"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{yearOptions.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>
</div>
+50 -6
View File
@@ -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<string, string>();
const columns = columnInfoItem.columns || [];
headers.forEach((headerText) => {
// columns에서 header.text가 일치하는 컬럼 찾기
const matchedColumn = columns.find(
(col) => col.header?.text === headerText || col.fieldName === headerText
);
if (matchedColumn?.fieldName) {
headerToFieldNameMap.set(headerText, matchedColumn.fieldName);
} else {
// 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성)
headerToFieldNameMap.set(headerText, headerText);
}
});
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
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,
@@ -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<string, string | number>[] {
return items.map((item) => {
const row: Record<string, string | number> = {};
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<Record<string, string | number>[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(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<Record<string, string | number>[]>([]);
return (
<>
<SearchForm />
<div className="relative">
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -41,14 +97,19 @@ const SeweragePenetrationRateInp = () => {
<SaveButton saveFunc={saveFunc} />
</div>
</div>
<DataGrid
columnInfo={SeweragePenetrationRateInpColumn}
rowStartNum={3}
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>}
<DataGrid
columnInfo={SeweragePenetrationRateInpColumn}
rowStartNum={3}
fixedColumns={1}
rowData={rowData}
setRowData={setRowData}
/>
</>
</div>
);
};
@@ -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 = () => {
</div>
<DataGrid
columnInfo={SeweragePenetrationRateInfoList}
isExcelImport={true}
isExcelImport={false}
fixedColumns={SeweragePenetrationRateInfoList.fixedColumns}
rowStartNum={SeweragePenetrationRateInfoList.rowStartNum}
rowData={rowData}
@@ -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;
@@ -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<Omit<SearchFormProps, 'onSubmit' | 'onReset' | 'defaultValues'>> = {
sido: true,
sgg: true,
gugun: true,
year: true,
order: false,
fctName: false,
@@ -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',
]
};
@@ -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: '하수도설치율(%)' } },
@@ -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);
}
}
@@ -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');
export const footerDecimal3ArrayByDivision = createSumFooterArrayByDivision('#,##0.000');
// 률(%) 평균 값(기본 소수점 1자리)
export const footerAvgDecimal = createAvgFooter('#,##0.0');
@@ -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<SewerageSupplyRateApiResponse> {
const response = await axios.get<SewerageSupplyRateApiResponse> (
`${ENDPOINT.ST_SERVICE.INPUT}/sewerage-supply-rate`,
{
params: params
}
);
return response.data;
}
}
export const statisticsInputAPi = new StatisticsInputAPiService();
@@ -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<SewerageSupplyRateResponseData> & {
message: string;
};
export interface SewerageSupplyRateRequestParams {
sido?: string;
gugun?: string;
bsnmCd?: string;
yr?: string;
demoYr?: string;
}
export interface StatisticsInputService {
getSewerageSupplyRateList(params: SewerageSupplyRateRequestParams): Promise<SewerageSupplyRateApiResponse>;
}
@@ -11,6 +11,7 @@ export interface Sgg {
export interface ApiResponse<T> {
status: string;
data: T;
message?: string;
}
export interface StatisticsService {
+2 -1
View File
@@ -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",
}
};
@@ -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) => (
<RGDataColumn
@@ -243,6 +270,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
{...(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,
+48 -6
View File
@@ -3,7 +3,8 @@ import { ValueType } from "realgrid"
import RealGrid, { GridFitStyle, type DisplayOptions } 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 "../type/computedFieldCallbacks"
import JSZip from "jszip"
import * as XLSX from "xlsx"
@@ -270,6 +271,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;
});
}
const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(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<string, string>();
headers.forEach((headerText) => {
// columns에서 header.text가 일치하는 컬럼 찾기
const matchedColumn = columns.find(
(col) => col.header?.text === headerText || col.fieldName === headerText
);
if (matchedColumn?.fieldName) {
headerToFieldNameMap.set(headerText, matchedColumn.fieldName);
} else {
// 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성)
headerToFieldNameMap.set(headerText, headerText);
}
});
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
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,
+50 -6
View File
@@ -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<string, string>();
const columns = columnInfoItem.columns || [];
headers.forEach((headerText) => {
// columns에서 header.text가 일치하는 컬럼 찾기
const matchedColumn = columns.find(
(col) => col.header?.text === headerText || col.fieldName === headerText
);
if (matchedColumn?.fieldName) {
headerToFieldNameMap.set(headerText, matchedColumn.fieldName);
} else {
// 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성)
headerToFieldNameMap.set(headerText, headerText);
}
});
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
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,
@@ -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;
@@ -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);
}
}