Merge branch 'iipbs/st/dev' into feature/review-skeleton-sein
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useCallback, useMemo } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
@@ -30,10 +30,14 @@ export default function WithBaseLayout({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
|
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
|
||||||
|
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
|
||||||
|
|
||||||
const addTab = useTabStore((state) => state.addTab);
|
const addTab = useTabStore((state) => state.addTab);
|
||||||
const setActiveTab = useTabStore((state) => state.setActiveTab);
|
const setActiveTab = useTabStore((state) => state.setActiveTab);
|
||||||
const setMenuList = useTabStore((state) => state.setMenuList);
|
const setMenuList = useTabStore((state) => state.setMenuList);
|
||||||
|
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||||
|
const menuList = useTabStore((state) => state.menuList);
|
||||||
|
const tabs = useTabStore((state) => state.tabs);
|
||||||
const addTabForGrid = gridTabStore((state) => state.addTab);
|
const addTabForGrid = gridTabStore((state) => state.addTab);
|
||||||
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
|
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
|
||||||
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
|
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
|
||||||
@@ -64,6 +68,50 @@ export default function WithBaseLayout({
|
|||||||
}
|
}
|
||||||
}, [pathname, setActiveTab, setActiveTabForGrid]);
|
}, [pathname, setActiveTab, setActiveTabForGrid]);
|
||||||
|
|
||||||
|
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
|
||||||
|
const currentPath = useMemo(() => {
|
||||||
|
if (tabs.length === 0) return "";
|
||||||
|
|
||||||
|
if (activeTabId && menuList) {
|
||||||
|
// menuList에서 활성 탭의 메뉴 찾기
|
||||||
|
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.menuEngNm === activeTabId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (item.children) {
|
||||||
|
const found = findMenuByEngNm(item.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeMenu = findMenuByEngNm(menuList);
|
||||||
|
|
||||||
|
if (activeMenu?.urlPath) {
|
||||||
|
return activeMenu.urlPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭에서 직접 찾기
|
||||||
|
if (activeTabId) {
|
||||||
|
const currentTabs = getTabStoreState().tabs;
|
||||||
|
const activeTab = currentTabs.find((t) => t.id === activeTabId);
|
||||||
|
if (activeTab?.menuItem?.urlPath) {
|
||||||
|
return activeTab.menuItem.urlPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentMenuInfo에서 가져오기
|
||||||
|
if (currentMenuInfo?.urlPath) {
|
||||||
|
return currentMenuInfo.urlPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값으로 pathname 사용
|
||||||
|
return pathname ?? "";
|
||||||
|
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
|
||||||
|
|
||||||
const handleMenuClick = useCallback((item: MenuItem) => {
|
const handleMenuClick = useCallback((item: MenuItem) => {
|
||||||
setCurrentMenuInfo(item);
|
setCurrentMenuInfo(item);
|
||||||
const addTabResult: boolean = addTab(item);
|
const addTabResult: boolean = addTab(item);
|
||||||
@@ -77,12 +125,12 @@ export default function WithBaseLayout({
|
|||||||
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
|
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
|
||||||
기존 화면을 닫은 후 이용해주세요.`);
|
기존 화면을 닫은 후 이용해주세요.`);
|
||||||
}
|
}
|
||||||
}, [setCurrentMenuInfo, addTab, addTabForGrid]);
|
}, [setCurrentMenuInfo, addTab, addTabForGrid, alert]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
queryClient={queryClient}
|
queryClient={queryClient}
|
||||||
currentPath={pathname ?? ""}
|
currentPath={currentPath}
|
||||||
handleMenuClick={handleMenuClick}
|
handleMenuClick={handleMenuClick}
|
||||||
>
|
>
|
||||||
<StatisticsPageLayout>{children}</StatisticsPageLayout>
|
<StatisticsPageLayout>{children}</StatisticsPageLayout>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const NotApplicableCheckButton = ({ isCheck, setIsCheck }: NotApplicableCheckBut
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(buttonVariants({ variant: "default", size: "default" }), "w-[124px] bg-[#ebebeb] text-[rgb(146 146 146)] text-opacity-100")}>
|
<div className={cn(buttonVariants({ variant: "default", size: "default" }), "w-[124px] bg-[#ebebeb] border border-[#999] text-[rgb(146 146 146)] text-opacity-100")}>
|
||||||
<label htmlFor="notApplicable">해당사항 없음</label>
|
<label htmlFor="notApplicable">해당사항 없음</label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="notApplicable"
|
id="notApplicable"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ValueType } from "realgrid"
|
|||||||
|
|
||||||
import RealGrid, { GridFitStyle } from "realgrid"
|
import RealGrid, { GridFitStyle } from "realgrid"
|
||||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
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 타입 확장
|
// RealGrid 타입 확장
|
||||||
declare module "realgrid" {
|
declare module "realgrid" {
|
||||||
@@ -18,6 +19,49 @@ declare module "realgrid" {
|
|||||||
|
|
||||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||||
|
|
||||||
|
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||||
|
interface RealGridDataProviderComparer {
|
||||||
|
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||||
|
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) => {
|
||||||
|
const get = dp.getValue?.bind(dp);
|
||||||
|
if (!get) return 0;
|
||||||
|
const val1 = get(row1, field);
|
||||||
|
const val2 = get(row2, field);
|
||||||
|
if (val1 == null && val2 == null) return 0;
|
||||||
|
if (val1 == null) return 1;
|
||||||
|
if (val2 == null) return -1;
|
||||||
|
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||||
|
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||||
|
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||||
|
if (isNaN(num1)) return 1;
|
||||||
|
if (isNaN(num2)) return -1;
|
||||||
|
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
||||||
const { columns, columnLayout } = columnInfo;
|
const { columns, columnLayout } = columnInfo;
|
||||||
const gridRef = useRef<RealGridReact>(null);
|
const gridRef = useRef<RealGridReact>(null);
|
||||||
@@ -28,54 +72,21 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
const gridView = gridRef.current.gridView;
|
const gridView = gridRef.current.gridView;
|
||||||
const dataProvider = gridRef.current.dataProvider;
|
const dataProvider = gridRef.current.dataProvider;
|
||||||
|
|
||||||
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
|
// setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음)
|
||||||
const fieldMap = new Map<string, string>();
|
const dp = dataProvider as RealGridDataProviderComparer;
|
||||||
|
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
|
||||||
columns.forEach(col => {
|
const numberComparer = createNumberComparer(dp);
|
||||||
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
|
const seen = new Set<string>();
|
||||||
fieldMap.set(col.fieldName, col.valueType);
|
columns.forEach((col) => {
|
||||||
fieldsToSet.push({
|
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
|
||||||
fieldName: col.fieldName,
|
seen.add(col.fieldName);
|
||||||
dataType: col.valueType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
|
|
||||||
if (fieldsToSet.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
// 기존 필드 정보 가져오기
|
dp.setDataComparer!(col.fieldName, numberComparer);
|
||||||
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
|
} catch (e) {
|
||||||
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
|
console.warn("setDataComparer:", e);
|
||||||
|
}
|
||||||
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
|
|
||||||
const fieldsToUpdate = fieldsToSet.filter(field => {
|
|
||||||
const existingDataType = existingFieldMap.get(field.fieldName);
|
|
||||||
return existingDataType !== field.dataType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fieldsToUpdate.length > 0) {
|
|
||||||
// 기존 필드 정보를 유지하면서 dataType만 업데이트
|
|
||||||
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
|
|
||||||
const allFields = existingFields.map(f => ({
|
|
||||||
fieldName: f.fieldName,
|
|
||||||
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 새로 추가할 필드가 있으면 추가
|
|
||||||
fieldsToSet.forEach(field => {
|
|
||||||
if (!existingFieldMap.has(field.fieldName)) {
|
|
||||||
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// setFields를 호출하여 필드의 dataType 업데이트
|
|
||||||
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
|
|
||||||
.setFields(allFields);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to set field dataType:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gridView.setDisplayOptions({
|
gridView.setDisplayOptions({
|
||||||
@@ -87,13 +98,32 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
// 헤더 체크박스 표시 여부 설정
|
// 헤더 체크박스 표시 여부 설정
|
||||||
gridView.setOptions({
|
gridView.setOptions({
|
||||||
checkBar: { visible: showHeaderCheckbox },
|
checkBar: { visible: showHeaderCheckbox },
|
||||||
|
rowIndicator: { visible: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
||||||
const hasFooter = columns.some(col => col.footer);
|
const hasFooter = columns.some(col => col.footer);
|
||||||
if (hasFooter) {
|
if (hasFooter) {
|
||||||
|
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
const hasMultiRowFooter = columns.some(col => {
|
||||||
|
const footer = col.footer;
|
||||||
|
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
|
||||||
|
});
|
||||||
|
if (hasArrayFooter || hasMultiRowFooter) {
|
||||||
|
// footer 배열의 길이를 확인하여 행 수 결정
|
||||||
|
const arrayFooterLengths = columns
|
||||||
|
.filter(col => col.footer && Array.isArray(col.footer))
|
||||||
|
.map(col => (col.footer as unknown[]).length);
|
||||||
|
const maxFooterLength = arrayFooterLengths.length > 0
|
||||||
|
? Math.max(...arrayFooterLengths)
|
||||||
|
: 2; // 기본값 2
|
||||||
|
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
|
||||||
|
gridView.setFooters?.(footerHeights);
|
||||||
|
} else {
|
||||||
gridView.footer.height = 40;
|
gridView.footer.height = 40;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 고정 컬럼 설정
|
// 고정 컬럼 설정
|
||||||
if (fixedColumns !== undefined && fixedColumns > 0) {
|
if (fixedColumns !== undefined && fixedColumns > 0) {
|
||||||
@@ -106,15 +136,113 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
summaryMode: "aggregate"
|
summaryMode: "aggregate"
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer의 valueCallback 설정
|
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||||
|
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||||
|
return layout.some(item => {
|
||||||
|
if (typeof item === 'string') return false;
|
||||||
|
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||||
|
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||||
|
return checkFooterUserSpans(item.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFooterUserSpans(columnLayout)) {
|
||||||
|
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gridRef.current?.gridView && columnLayout) {
|
||||||
|
gridRef.current.gridView.setColumnLayout?.(columnLayout);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||||
|
// setFooters 호출 후 footer 배열 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentGridView = gridRef.current?.gridView;
|
||||||
|
if (!currentGridView || !currentGridView.columnByName) {
|
||||||
|
console.warn('GridView not available for footer setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
if (hasArrayFooter) {
|
||||||
|
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||||
|
const columnsWithFooter: 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) {
|
||||||
|
if (Array.isArray(col.footer)) {
|
||||||
|
colInfo.footer = col.footer;
|
||||||
|
} else {
|
||||||
|
colInfo.footer = {
|
||||||
|
text: col.footer.text,
|
||||||
|
expression: col.footer.expression,
|
||||||
|
numberFormat: col.footer.numberFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||||
|
|
||||||
|
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||||
|
setTimeout(() => {
|
||||||
columns.forEach(col => {
|
columns.forEach(col => {
|
||||||
if (col.footer && col.footer.valueCallback) {
|
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||||
const column = gridView.columnByName(col.name);
|
|
||||||
if (column && column.footer) {
|
try {
|
||||||
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
// footer 배열의 각 요소에 valueCallback 설정
|
||||||
|
col.footer.forEach((footerItem, index) => {
|
||||||
|
if (footerItem.valueCallback && column.footer) {
|
||||||
|
// footer가 배열인 경우
|
||||||
|
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||||
|
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set columns with footer array:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column || !column.footer) return;
|
||||||
|
|
||||||
|
if (col.footer.valueCallback) {
|
||||||
|
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
||||||
|
|
||||||
@@ -126,6 +254,12 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
autoGenerateField={true}
|
autoGenerateField={true}
|
||||||
layout={columnLayout}
|
layout={columnLayout}
|
||||||
rows={rowData}
|
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) => (
|
{columns.map((column) => (
|
||||||
<RGDataColumn
|
<RGDataColumn
|
||||||
@@ -136,7 +270,8 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
||||||
header={column.header}
|
header={column.header}
|
||||||
editable={column.editable}
|
editable={column.editable}
|
||||||
footer={column.footer ? {
|
{...(column.visible === false && { visible: false })}
|
||||||
|
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||||
text: column.footer.text,
|
text: column.footer.text,
|
||||||
expression: column.footer.expression,
|
expression: column.footer.expression,
|
||||||
numberFormat: column.footer.numberFormat,
|
numberFormat: column.footer.numberFormat,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useRef, useEffect } from "react"
|
import { useRef, useEffect } from "react";
|
||||||
import { ValueType } from "realgrid"
|
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 { useAlertStore } from "@/shared/lib";
|
||||||
|
|
||||||
import { Button } from "@common/components";
|
import { Button } from "@common/components";
|
||||||
import { useTabStore } from "@/store/tab-store"
|
import { useTabStore } from "@/store/tab-store";
|
||||||
|
|
||||||
// RealGrid 타입 확장
|
// RealGrid 타입 확장
|
||||||
declare module "realgrid" {
|
declare module "realgrid" {
|
||||||
@@ -36,10 +38,263 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||||
|
|
||||||
|
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||||
|
interface RealGridDataProviderComparer {
|
||||||
|
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||||
|
getValue?(row: number, field: string | number): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
|
||||||
|
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
|
||||||
|
return (field, row1, row2) => {
|
||||||
|
const get = dp.getValue?.bind(dp);
|
||||||
|
if (!get) return 0;
|
||||||
|
const val1 = get(row1, field);
|
||||||
|
const val2 = get(row2, field);
|
||||||
|
if (val1 == null && val2 == null) return 0;
|
||||||
|
if (val1 == null) return 1;
|
||||||
|
if (val2 == null) return -1;
|
||||||
|
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||||
|
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||||
|
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||||
|
if (isNaN(num1)) return 1;
|
||||||
|
if (isNaN(num2)) return -1;
|
||||||
|
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
|
||||||
|
function toNum(v: unknown): number {
|
||||||
|
if (v == null) return NaN;
|
||||||
|
if (typeof v === "number") return isNaN(v) ? NaN : v;
|
||||||
|
return parseFloat(String(v).replace(/,/g, "")) || NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
|
||||||
|
function updateHeaderSortIndicator(
|
||||||
|
gridView: {
|
||||||
|
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
},
|
||||||
|
columns: { fieldName?: string; name: string }[],
|
||||||
|
sortDir: Record<string, "asc" | "desc" | "none">
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
columns.forEach((col) => {
|
||||||
|
if (!col.fieldName && !col.name) return;
|
||||||
|
|
||||||
|
// 컬럼 찾기
|
||||||
|
let gridCol: { fieldName?: string; name?: string } | undefined;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const c = gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i);
|
||||||
|
if (!c) break;
|
||||||
|
if (c.fieldName === (col.fieldName || col.name) || c.name === (col.fieldName || col.name)) {
|
||||||
|
gridCol = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gridCol) {
|
||||||
|
gridCol = gridView.columnByName?.(col.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gridCol) return;
|
||||||
|
|
||||||
|
const fieldName = gridCol.fieldName || gridCol.name || col.fieldName || col.name;
|
||||||
|
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
|
||||||
|
|
||||||
|
// DOM에서 헤더 요소 찾기
|
||||||
|
const gridEl1 = document.querySelector('[data-grid-id]') as HTMLElement | null;
|
||||||
|
const gridEl2 = document.querySelector('.rg-grid') as HTMLElement | null;
|
||||||
|
const gridEl = gridEl1 || gridEl2;
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
// 헤더 셀 찾기 (fieldName 또는 name으로)
|
||||||
|
const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"]');
|
||||||
|
let targetCell: HTMLElement | null = null;
|
||||||
|
|
||||||
|
headerCells.forEach((cell) => {
|
||||||
|
const el = cell as HTMLElement;
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
const colName = col.name;
|
||||||
|
const colFieldName = col.fieldName;
|
||||||
|
if (text && (text.includes(colName) || (colFieldName && text.includes(colFieldName)))) {
|
||||||
|
targetCell = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetCell) return;
|
||||||
|
|
||||||
|
const headerCell = targetCell as HTMLElement;
|
||||||
|
|
||||||
|
// 기존 화살표 제거
|
||||||
|
const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null;
|
||||||
|
if (existingIndicator) {
|
||||||
|
existingIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화살표 추가
|
||||||
|
if (direction === "asc" || direction === "desc") {
|
||||||
|
const indicator = document.createElement("span");
|
||||||
|
indicator.className = "rg-sort-indicator";
|
||||||
|
indicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||||
|
indicator.style.marginLeft = "4px";
|
||||||
|
indicator.style.fontSize = "10px";
|
||||||
|
indicator.style.color = "#666";
|
||||||
|
indicator.style.display = "inline-block";
|
||||||
|
indicator.style.verticalAlign = "middle";
|
||||||
|
headerCell.appendChild(indicator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("updateHeaderSortIndicator error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
|
||||||
|
function createOnSortingHandler(
|
||||||
|
columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[],
|
||||||
|
setRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>,
|
||||||
|
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
|
||||||
|
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>
|
||||||
|
) {
|
||||||
|
const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))];
|
||||||
|
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
|
||||||
|
// 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
|
||||||
|
const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName);
|
||||||
|
const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0];
|
||||||
|
const groupField = mergeCellCol?.fieldName || null;
|
||||||
|
const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음
|
||||||
|
const hasMergeCells = mergeCellCols.length > 0;
|
||||||
|
|
||||||
|
return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) {
|
||||||
|
const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } };
|
||||||
|
const idx = fields?.[0];
|
||||||
|
if (idx === undefined) return true;
|
||||||
|
let sortField: string | null = null;
|
||||||
|
if (typeof idx === "string") sortField = idx;
|
||||||
|
else {
|
||||||
|
const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0);
|
||||||
|
sortField = (colObj?.fieldName ?? colObj?.name) ?? null;
|
||||||
|
}
|
||||||
|
if (!sortField) return true;
|
||||||
|
const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField));
|
||||||
|
if (!col || col.sortdisable) return true;
|
||||||
|
|
||||||
|
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
|
||||||
|
if (!hasMergeCells || !groupField) return true;
|
||||||
|
|
||||||
|
const ds = grid.getDataSource?.();
|
||||||
|
if (!ds) return true;
|
||||||
|
const cnt = ds.getRowCount();
|
||||||
|
const rows: Record<string, string | number>[] = [];
|
||||||
|
for (let i = 0; i < cnt; i++) {
|
||||||
|
const r: Record<string, string | number> = {};
|
||||||
|
for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
|
||||||
|
rows.push(r);
|
||||||
|
}
|
||||||
|
const prev = sortDirRef.current[sortField] ?? "none";
|
||||||
|
const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds);
|
||||||
|
|
||||||
|
if (prev === "desc") {
|
||||||
|
const orig = originalRowsRef.current[sortField];
|
||||||
|
if (orig && setRows) {
|
||||||
|
setRows(orig);
|
||||||
|
setRowData(orig);
|
||||||
|
}
|
||||||
|
sortDirRef.current[sortField] = "none";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev === "none") {
|
||||||
|
originalRowsRef.current[sortField] = rows.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
const sortAsc = prev !== "asc";
|
||||||
|
sortDirRef.current[sortField] = sortAsc ? "asc" : "desc";
|
||||||
|
const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType);
|
||||||
|
setRows?.(sorted);
|
||||||
|
setRowData(sorted);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
|
||||||
|
function sortRowsGroupPreserving(
|
||||||
|
rows: Record<string, string | number>[],
|
||||||
|
sortField: string,
|
||||||
|
sortAsc: boolean,
|
||||||
|
opt: { groupField: string; orderByField?: string },
|
||||||
|
valueType?: ValueType
|
||||||
|
): Record<string, string | number>[] {
|
||||||
|
const groups = new Map<string, Record<string, string | number>[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = String(r[opt.groupField] ?? "");
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k)!.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueType에 따라 비교 함수 선택
|
||||||
|
const compareValue = (val: unknown): number | string => {
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
return toNum(val);
|
||||||
|
}
|
||||||
|
// 텍스트나 기타 타입은 문자열로 비교
|
||||||
|
return String(val ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const rep = (arr: Record<string, string | number>[]): number | string => {
|
||||||
|
const values = arr.map((r) => compareValue(r[sortField]));
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
|
if (nums.length === 0) return sortAsc ? Infinity : -Infinity;
|
||||||
|
return sortAsc ? Math.min(...nums) : Math.max(...nums);
|
||||||
|
} else {
|
||||||
|
// 문자열 정렬
|
||||||
|
const strs = values.filter((v): v is string => typeof v === "string");
|
||||||
|
if (strs.length === 0) return sortAsc ? "\uffff" : "";
|
||||||
|
strs.sort();
|
||||||
|
return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = [...groups.entries()].sort((a, b) => {
|
||||||
|
const valA = rep(a[1]);
|
||||||
|
const valB = rep(b[1]);
|
||||||
|
if (typeof valA === "number" && typeof valB === "number") {
|
||||||
|
return (sortAsc ? 1 : -1) * (valA - valB);
|
||||||
|
}
|
||||||
|
// 문자열 비교
|
||||||
|
const strA = String(valA);
|
||||||
|
const strB = String(valB);
|
||||||
|
return (sortAsc ? 1 : -1) * strA.localeCompare(strB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries.flatMap(([, arr]) => arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
||||||
const { columns, columnLayout } = columnInfo;
|
const { columns, columnLayout } = columnInfo;
|
||||||
const gridRef = useRef<RealGridReact>(null);
|
const gridRef = useRef<RealGridReact>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sortDirRef = useRef<Record<string, "asc" | "desc" | "none">>({});
|
||||||
|
const originalRowsRef = useRef<Record<string, Record<string, string | number>[]>>({});
|
||||||
const { setMessage: alert } = useAlertStore((state) => state);
|
const { setMessage: alert } = useAlertStore((state) => state);
|
||||||
const activeTabId = useTabStore((state) => state.activeTabId);
|
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||||
const menuList = useTabStore((state) => state.menuList);
|
const menuList = useTabStore((state) => state.menuList);
|
||||||
@@ -135,6 +390,21 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
||||||
const dataStartRow = maxHeaderRowIndex + 1;
|
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>[] = [];
|
const dataRows: Record<string, string | number>[] = [];
|
||||||
for (let row = dataStartRow; row <= range.e.r; row++) {
|
for (let row = dataStartRow; row <= range.e.r; row++) {
|
||||||
@@ -145,6 +415,8 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
const col = range.s.c + colIndex;
|
const col = range.s.c + colIndex;
|
||||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
||||||
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
||||||
|
// 헤더 텍스트를 fieldName으로 변환
|
||||||
|
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
|
||||||
|
|
||||||
let cellValue: string | number = '';
|
let cellValue: string | number = '';
|
||||||
if (cell && cell.v !== undefined && cell.v !== null) {
|
if (cell && cell.v !== undefined && cell.v !== null) {
|
||||||
@@ -167,7 +439,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
hasData = true;
|
hasData = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
rowData[headerName] = cellValue;
|
rowData[fieldName] = cellValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터가 하나라도 있는 행만 추가
|
// 데이터가 하나라도 있는 행만 추가
|
||||||
@@ -209,55 +481,29 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
const gridView = gridRef.current.gridView;
|
const gridView = gridRef.current.gridView;
|
||||||
const dataProvider = gridRef.current.dataProvider;
|
const dataProvider = gridRef.current.dataProvider;
|
||||||
|
|
||||||
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
|
||||||
const fieldMap = new Map<string, string>();
|
const hasMergeCells = columns.some((col) => col.mergeCells === true);
|
||||||
|
|
||||||
columns.forEach(col => {
|
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
|
||||||
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
|
const dp = dataProvider as RealGridDataProviderComparer;
|
||||||
fieldMap.set(col.fieldName, col.valueType);
|
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
|
||||||
fieldsToSet.push({
|
const numberComparer = createNumberComparer(dp);
|
||||||
fieldName: col.fieldName,
|
const seen = new Set<string>();
|
||||||
dataType: col.valueType
|
columns.forEach((col) => {
|
||||||
});
|
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
|
||||||
}
|
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
|
||||||
});
|
seen.add(col.fieldName);
|
||||||
|
|
||||||
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
|
|
||||||
if (fieldsToSet.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
// 기존 필드 정보 가져오기
|
dp.setDataComparer!(col.fieldName, numberComparer);
|
||||||
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
|
} catch (e) {
|
||||||
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
|
console.warn("setDataComparer:", e);
|
||||||
|
}
|
||||||
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
|
|
||||||
const fieldsToUpdate = fieldsToSet.filter(field => {
|
|
||||||
const existingDataType = existingFieldMap.get(field.fieldName);
|
|
||||||
return existingDataType !== field.dataType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fieldsToUpdate.length > 0) {
|
|
||||||
// 기존 필드 정보를 유지하면서 dataType만 업데이트
|
|
||||||
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
|
|
||||||
const allFields = existingFields.map(f => ({
|
|
||||||
fieldName: f.fieldName,
|
|
||||||
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 새로 추가할 필드가 있으면 추가
|
|
||||||
fieldsToSet.forEach(field => {
|
|
||||||
if (!existingFieldMap.has(field.fieldName)) {
|
|
||||||
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// setFields를 호출하여 필드의 dataType 업데이트
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
|
||||||
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
|
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
|
||||||
.setFields(allFields);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to set field dataType:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
|
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
|
||||||
gridView.header.height = 60;
|
gridView.header.height = 60;
|
||||||
@@ -265,13 +511,32 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
// 헤더 체크박스 표시 여부 설정
|
// 헤더 체크박스 표시 여부 설정
|
||||||
gridView.setOptions({
|
gridView.setOptions({
|
||||||
checkBar: { visible: showHeaderCheckbox },
|
checkBar: { visible: showHeaderCheckbox },
|
||||||
|
rowIndicator: { visible: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
||||||
const hasFooter = columns.some(col => col.footer);
|
const hasFooter = columns.some(col => col.footer);
|
||||||
if (hasFooter) {
|
if (hasFooter) {
|
||||||
|
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
const hasMultiRowFooter = columns.some(col => {
|
||||||
|
const footer = col.footer;
|
||||||
|
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
|
||||||
|
});
|
||||||
|
if (hasArrayFooter || hasMultiRowFooter) {
|
||||||
|
// footer 배열의 길이를 확인하여 행 수 결정
|
||||||
|
const arrayFooterLengths = columns
|
||||||
|
.filter(col => col.footer && Array.isArray(col.footer))
|
||||||
|
.map(col => (col.footer as unknown[]).length);
|
||||||
|
const maxFooterLength = arrayFooterLengths.length > 0
|
||||||
|
? Math.max(...arrayFooterLengths)
|
||||||
|
: 2; // 기본값 2
|
||||||
|
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
|
||||||
|
gridView.setFooters?.(footerHeights);
|
||||||
|
} else {
|
||||||
gridView.footer.height = 40;
|
gridView.footer.height = 40;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 고정 컬럼 설정
|
// 고정 컬럼 설정
|
||||||
if (fixedColumns !== undefined && fixedColumns > 0) {
|
if (fixedColumns !== undefined && fixedColumns > 0) {
|
||||||
@@ -284,18 +549,117 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
summaryMode: "aggregate"
|
summaryMode: "aggregate"
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer의 valueCallback 설정
|
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||||
|
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||||
|
return layout.some(item => {
|
||||||
|
if (typeof item === 'string') return false;
|
||||||
|
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||||
|
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||||
|
return checkFooterUserSpans(item.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFooterUserSpans(columnLayout)) {
|
||||||
|
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gridRef.current?.gridView && columnLayout) {
|
||||||
|
gridRef.current.gridView.setColumnLayout?.(columnLayout);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||||
|
// setFooters 호출 후 footer 배열 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentGridView = gridRef.current?.gridView;
|
||||||
|
if (!currentGridView || !currentGridView.columnByName) {
|
||||||
|
console.warn('GridView not available for footer setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
if (hasArrayFooter) {
|
||||||
|
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||||
|
const columnsWithFooter: 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) {
|
||||||
|
if (Array.isArray(col.footer)) {
|
||||||
|
colInfo.footer = col.footer;
|
||||||
|
} else {
|
||||||
|
colInfo.footer = {
|
||||||
|
text: col.footer.text,
|
||||||
|
expression: col.footer.expression,
|
||||||
|
numberFormat: col.footer.numberFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||||
|
|
||||||
|
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||||
|
setTimeout(() => {
|
||||||
columns.forEach(col => {
|
columns.forEach(col => {
|
||||||
if (col.footer && col.footer.valueCallback) {
|
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||||
const column = gridView.columnByName(col.name);
|
|
||||||
if (column && column.footer) {
|
try {
|
||||||
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
// footer 배열의 각 요소에 valueCallback 설정
|
||||||
|
col.footer.forEach((footerItem, index) => {
|
||||||
|
if (footerItem.valueCallback && column.footer) {
|
||||||
|
// footer가 배열인 경우
|
||||||
|
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||||
|
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set columns with footer array:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column || !column.footer) return;
|
||||||
|
|
||||||
|
if (col.footer.valueCallback) {
|
||||||
|
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
|
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
|
||||||
<input
|
<input
|
||||||
@@ -325,6 +689,13 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
autoGenerateField={true}
|
autoGenerateField={true}
|
||||||
layout={columnLayout}
|
layout={columnLayout}
|
||||||
rows={rowData}
|
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) => (
|
{columns.map((column) => (
|
||||||
<RGDataColumn
|
<RGDataColumn
|
||||||
@@ -335,7 +706,8 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
||||||
header={column.header}
|
header={column.header}
|
||||||
editable={column.editable}
|
editable={column.editable}
|
||||||
footer={column.footer ? {
|
{...(column.visible === false && { visible: false })}
|
||||||
|
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||||
text: column.footer.text,
|
text: column.footer.text,
|
||||||
expression: column.footer.expression,
|
expression: column.footer.expression,
|
||||||
numberFormat: column.footer.numberFormat,
|
numberFormat: column.footer.numberFormat,
|
||||||
|
|||||||
@@ -31,10 +31,17 @@ const getSggListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg
|
|||||||
const SearchForm = (props: SearchFormProps = {}) => {
|
const SearchForm = (props: SearchFormProps = {}) => {
|
||||||
const { setMessage: alert } = useAlertStore((state) => state);
|
const { setMessage: alert } = useAlertStore((state) => state);
|
||||||
const mergedProps = { ...defaultSearchFormProps, ...props };
|
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>({
|
const [values, setValues] = useState<SearchFormValues>({
|
||||||
sido: props.defaultValues?.sido || "",
|
sido: props.defaultValues?.sido || "",
|
||||||
sgg: props.defaultValues?.sgg || "",
|
gugun: props.defaultValues?.gugun || "",
|
||||||
year: props.defaultValues?.year || "",
|
year: props.defaultValues?.year || String(defaultYear),
|
||||||
order: props.defaultValues?.order || "",
|
order: props.defaultValues?.order || "",
|
||||||
fctName: props.defaultValues?.fctName || "",
|
fctName: props.defaultValues?.fctName || "",
|
||||||
});
|
});
|
||||||
@@ -83,6 +90,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
props.onSubmit?.(values);
|
props.onSubmit?.(values);
|
||||||
@@ -91,14 +99,13 @@ const SearchForm = (props: SearchFormProps = {}) => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const resetValues: SearchFormValues = {
|
const resetValues: SearchFormValues = {
|
||||||
sido: "",
|
sido: "",
|
||||||
sgg: "",
|
gugun: "",
|
||||||
year: "",
|
year: String(defaultYear),
|
||||||
order: "",
|
order: "",
|
||||||
fctName: "",
|
fctName: "",
|
||||||
};
|
};
|
||||||
setValues(resetValues);
|
setValues(resetValues);
|
||||||
props.onReset?.();
|
props.onReset?.();
|
||||||
props.onSubmit?.(resetValues);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,12 +117,12 @@ const SearchForm = (props: SearchFormProps = {}) => {
|
|||||||
<Label className="text-label">
|
<Label className="text-label">
|
||||||
시도
|
시도
|
||||||
</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]">
|
<SelectTrigger className="w-[12rem]">
|
||||||
<SelectValue placeholder="전체"></SelectValue>
|
<SelectValue placeholder="선택"></SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">전체</SelectItem>
|
<SelectItem value="ALL">선택</SelectItem>
|
||||||
{sidoList?.data?.map((sido) => (
|
{sidoList?.data?.map((sido) => (
|
||||||
<SelectItem key={sido.adstDstrCd} value={sido.sido}>
|
<SelectItem key={sido.adstDstrCd} value={sido.sido}>
|
||||||
{sido.sido}
|
{sido.sido}
|
||||||
@@ -125,12 +132,12 @@ const SearchForm = (props: SearchFormProps = {}) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{mergedProps.sgg &&
|
{mergedProps.gugun &&
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-label">
|
<Label className="text-label">
|
||||||
시군구
|
시군구
|
||||||
</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]">
|
<SelectTrigger className="w-[12rem]">
|
||||||
<SelectValue placeholder="전체"></SelectValue>
|
<SelectValue placeholder="전체"></SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -152,10 +159,14 @@ const SearchForm = (props: SearchFormProps = {}) => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Select value={values.year} onValueChange={(value) => setValues({ ...values, year: value })}>
|
<Select value={values.year} onValueChange={(value) => setValues({ ...values, year: value })}>
|
||||||
<SelectTrigger className="w-[10rem]">
|
<SelectTrigger className="w-[10rem]">
|
||||||
<SelectValue placeholder="전체"></SelectValue>
|
<SelectValue placeholder="선택"></SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">전체</SelectItem>
|
{yearOptions.map((year) => (
|
||||||
|
<SelectItem key={year} value={String(year)}>
|
||||||
|
{year}년
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ValueType } from "realgrid"
|
|||||||
|
|
||||||
import RealGrid, { GridFitStyle } from "realgrid"
|
import RealGrid, { GridFitStyle } from "realgrid"
|
||||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
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 JSZip from "jszip"
|
||||||
import * as XLSX from "xlsx"
|
import * as XLSX from "xlsx"
|
||||||
import { useAlertStore } from "@/shared/lib";
|
import { useAlertStore } from "@/shared/lib";
|
||||||
@@ -24,6 +25,16 @@ declare module "realgrid" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RealGridDataProviderFill {
|
||||||
|
fillJsonData?(data: Record<string, string | number>[], options?: { fillMode?: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||||
|
interface RealGridDataProviderComparer {
|
||||||
|
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||||
|
getValue?(row: number, field: string | number): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// Window 인터페이스 확장
|
// Window 인터페이스 확장
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -38,10 +49,270 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||||
|
|
||||||
|
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
|
||||||
|
function toNum(v: unknown): number {
|
||||||
|
if (v == null) return NaN;
|
||||||
|
if (typeof v === "number") return isNaN(v) ? NaN : v;
|
||||||
|
return parseFloat(String(v).replace(/,/g, "")) || NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
|
||||||
|
function updateHeaderSortIndicator(
|
||||||
|
gridView: {
|
||||||
|
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
getElement?: () => HTMLElement | null;
|
||||||
|
container?: HTMLElement;
|
||||||
|
},
|
||||||
|
columns: { fieldName?: string; name: string }[],
|
||||||
|
sortDir: Record<string, "asc" | "desc" | "none">,
|
||||||
|
gridElement?: HTMLElement | null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 그리드 DOM 요소 찾기
|
||||||
|
let gridEl: HTMLElement | null = null;
|
||||||
|
if (gridElement) {
|
||||||
|
gridEl = gridElement;
|
||||||
|
} else if (gridView.getElement) {
|
||||||
|
gridEl = gridView.getElement();
|
||||||
|
} else if (gridView.container) {
|
||||||
|
gridEl = gridView.container;
|
||||||
|
} else {
|
||||||
|
// 폴백: 모든 그리드 중에서 찾기
|
||||||
|
const allGrids = document.querySelectorAll('.rg-grid, [data-grid-id]');
|
||||||
|
if (allGrids.length > 0) {
|
||||||
|
// 활성 탭의 그리드를 찾기 위해 TabsContent 내부의 그리드 찾기
|
||||||
|
const activeTabContent = document.querySelector('[data-state="active"]')?.closest('[role="tabpanel"]');
|
||||||
|
if (activeTabContent) {
|
||||||
|
gridEl = activeTabContent.querySelector('.rg-grid, [data-grid-id]') as HTMLElement;
|
||||||
|
}
|
||||||
|
if (!gridEl && allGrids.length === 1) {
|
||||||
|
gridEl = allGrids[0] as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
columns.forEach((col) => {
|
||||||
|
if (!col.fieldName && !col.name) return;
|
||||||
|
|
||||||
|
const fieldName = col.fieldName || col.name;
|
||||||
|
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
|
||||||
|
if (direction === "none") return;
|
||||||
|
|
||||||
|
// 헤더 셀 찾기 (fieldName 또는 name으로)
|
||||||
|
const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"], .rg-header');
|
||||||
|
let targetCell: HTMLElement | null = null;
|
||||||
|
|
||||||
|
headerCells.forEach((cell) => {
|
||||||
|
const el = cell as HTMLElement;
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
const colName = col.name;
|
||||||
|
const colFieldName = col.fieldName;
|
||||||
|
// 헤더 텍스트와 컬럼 이름이 일치하는지 확인
|
||||||
|
if (text && (
|
||||||
|
text === colName ||
|
||||||
|
text === colFieldName ||
|
||||||
|
text.includes(colName) ||
|
||||||
|
(colFieldName && text.includes(colFieldName))
|
||||||
|
)) {
|
||||||
|
targetCell = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetCell) return;
|
||||||
|
|
||||||
|
const headerCell = targetCell as HTMLElement;
|
||||||
|
|
||||||
|
// 기존 화살표 제거
|
||||||
|
const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null;
|
||||||
|
if (existingIndicator) {
|
||||||
|
existingIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화살표 추가
|
||||||
|
const indicator = document.createElement("span");
|
||||||
|
indicator.className = "rg-sort-indicator";
|
||||||
|
indicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||||
|
indicator.style.marginLeft = "4px";
|
||||||
|
indicator.style.fontSize = "10px";
|
||||||
|
indicator.style.color = "#666";
|
||||||
|
indicator.style.display = "inline-block";
|
||||||
|
indicator.style.verticalAlign = "middle";
|
||||||
|
headerCell.appendChild(indicator);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("updateHeaderSortIndicator error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
|
||||||
|
function createOnSortingHandler(
|
||||||
|
columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[],
|
||||||
|
setRowData: (updater: React.SetStateAction<Record<string, string | number>[]>) => void,
|
||||||
|
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
|
||||||
|
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>,
|
||||||
|
findGridElement?: () => HTMLElement | null
|
||||||
|
) {
|
||||||
|
const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))];
|
||||||
|
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
|
||||||
|
// 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
|
||||||
|
const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName);
|
||||||
|
const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0];
|
||||||
|
const groupField = mergeCellCol?.fieldName || null;
|
||||||
|
const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음
|
||||||
|
const hasMergeCells = mergeCellCols.length > 0;
|
||||||
|
|
||||||
|
return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) {
|
||||||
|
const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } };
|
||||||
|
const idx = fields?.[0];
|
||||||
|
if (idx === undefined) return true;
|
||||||
|
let sortField: string | null = null;
|
||||||
|
if (typeof idx === "string") sortField = idx;
|
||||||
|
else {
|
||||||
|
const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0);
|
||||||
|
sortField = (colObj?.fieldName ?? colObj?.name) ?? null;
|
||||||
|
}
|
||||||
|
if (!sortField) return true;
|
||||||
|
const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField));
|
||||||
|
if (!col || col.sortdisable) return true;
|
||||||
|
|
||||||
|
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
|
||||||
|
if (!hasMergeCells || !groupField) return true;
|
||||||
|
|
||||||
|
const ds = grid.getDataSource?.();
|
||||||
|
if (!ds) return true;
|
||||||
|
const cnt = ds.getRowCount();
|
||||||
|
const rows: Record<string, string | number>[] = [];
|
||||||
|
for (let i = 0; i < cnt; i++) {
|
||||||
|
const r: Record<string, string | number> = {};
|
||||||
|
for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
|
||||||
|
rows.push(r);
|
||||||
|
}
|
||||||
|
const prev = sortDirRef.current[sortField] ?? "none";
|
||||||
|
const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds);
|
||||||
|
|
||||||
|
if (prev === "desc") {
|
||||||
|
const orig = originalRowsRef.current[sortField];
|
||||||
|
if (orig && setRows) {
|
||||||
|
setRows(orig);
|
||||||
|
setRowData(orig);
|
||||||
|
}
|
||||||
|
sortDirRef.current[sortField] = "none";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev === "none") {
|
||||||
|
originalRowsRef.current[sortField] = rows.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
const sortAsc = prev !== "asc";
|
||||||
|
sortDirRef.current[sortField] = sortAsc ? "asc" : "desc";
|
||||||
|
const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType);
|
||||||
|
setRows?.(sorted);
|
||||||
|
setRowData(sorted);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
|
||||||
|
function sortRowsGroupPreserving(
|
||||||
|
rows: Record<string, string | number>[],
|
||||||
|
sortField: string,
|
||||||
|
sortAsc: boolean,
|
||||||
|
opt: { groupField: string; orderByField?: string },
|
||||||
|
valueType?: ValueType
|
||||||
|
): Record<string, string | number>[] {
|
||||||
|
const groups = new Map<string, Record<string, string | number>[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = String(r[opt.groupField] ?? "");
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k)!.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueType에 따라 비교 함수 선택
|
||||||
|
const compareValue = (val: unknown): number | string => {
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
return toNum(val);
|
||||||
|
}
|
||||||
|
// 텍스트나 기타 타입은 문자열로 비교
|
||||||
|
return String(val ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const rep = (arr: Record<string, string | number>[]): number | string => {
|
||||||
|
const values = arr.map((r) => compareValue(r[sortField]));
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
|
if (nums.length === 0) return sortAsc ? Infinity : -Infinity;
|
||||||
|
return sortAsc ? Math.min(...nums) : Math.max(...nums);
|
||||||
|
} else {
|
||||||
|
// 문자열 정렬
|
||||||
|
const strs = values.filter((v): v is string => typeof v === "string");
|
||||||
|
if (strs.length === 0) return sortAsc ? "\uffff" : "";
|
||||||
|
strs.sort();
|
||||||
|
return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = [...groups.entries()].sort((a, b) => {
|
||||||
|
const valA = rep(a[1]);
|
||||||
|
const valB = rep(b[1]);
|
||||||
|
if (typeof valA === "number" && typeof valB === "number") {
|
||||||
|
return (sortAsc ? 1 : -1) * (valA - valB);
|
||||||
|
}
|
||||||
|
// 문자열 비교
|
||||||
|
const strA = String(valA);
|
||||||
|
const strB = String(valB);
|
||||||
|
return (sortAsc ? 1 : -1) * strA.localeCompare(strB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries.flatMap(([, arr]) => arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const get = dp.getValue?.bind(dp);
|
||||||
|
if (!get) return 0;
|
||||||
|
const val1 = get(row1, field);
|
||||||
|
const val2 = get(row2, field);
|
||||||
|
if (val1 == null && val2 == null) return 0;
|
||||||
|
if (val1 == null) return 1;
|
||||||
|
if (val2 == null) return -1;
|
||||||
|
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||||
|
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||||
|
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||||
|
if (isNaN(num1)) return 1;
|
||||||
|
if (isNaN(num2)) return -1;
|
||||||
|
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
|
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
|
||||||
// 각 탭별로 그리드 ref와 데이터를 관리
|
// 각 탭별로 그리드 ref와 데이터를 관리
|
||||||
const gridRefs = useRef<Map<string, RealGridReact>>(new Map());
|
const gridRefs = useRef<Map<string, RealGridReact>>(new Map());
|
||||||
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
||||||
|
// 각 탭별로 정렬 상태 관리
|
||||||
|
const sortDirRefs = useRef<Map<string, React.MutableRefObject<Record<string, "asc" | "desc" | "none">>>>(new Map());
|
||||||
|
const originalRowsRefs = useRef<Map<string, React.MutableRefObject<Record<string, Record<string, string | number>[]>>>>(new Map());
|
||||||
const { setMessage: alert } = useAlertStore((state) => state);
|
const { setMessage: alert } = useAlertStore((state) => state);
|
||||||
const activeTabId = useTabStore((state) => state.activeTabId);
|
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||||
const menuList = useTabStore((state) => state.menuList);
|
const menuList = useTabStore((state) => state.menuList);
|
||||||
@@ -152,6 +423,22 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
||||||
const dataStartRow = maxHeaderRowIndex + 1;
|
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>[] = [];
|
const dataRows: Record<string, string | number>[] = [];
|
||||||
for (let row = dataStartRow; row <= range.e.r; row++) {
|
for (let row = dataStartRow; row <= range.e.r; row++) {
|
||||||
@@ -162,6 +449,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
const col = range.s.c + colIndex;
|
const col = range.s.c + colIndex;
|
||||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
||||||
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
||||||
|
// 헤더 텍스트를 fieldName으로 변환
|
||||||
|
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
|
||||||
|
|
||||||
let cellValue: string | number = '';
|
let cellValue: string | number = '';
|
||||||
if (cell && cell.v !== undefined && cell.v !== null) {
|
if (cell && cell.v !== undefined && cell.v !== null) {
|
||||||
@@ -184,7 +473,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
hasData = true;
|
hasData = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
rowData[headerName] = cellValue;
|
rowData[fieldName] = cellValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터가 하나라도 있는 행만 추가
|
// 데이터가 하나라도 있는 행만 추가
|
||||||
@@ -244,6 +533,30 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
}
|
}
|
||||||
}, [rows, columnInfo]);
|
}, [rows, columnInfo]);
|
||||||
|
|
||||||
|
// rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등)
|
||||||
|
useEffect(() => {
|
||||||
|
columnInfo.forEach((info) => {
|
||||||
|
const tabKey = info.tabKey;
|
||||||
|
const gridRef = gridRefs.current.get(tabKey);
|
||||||
|
const currentData = rowDataMap.get(tabKey);
|
||||||
|
|
||||||
|
if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const dp = gridRef.dataProvider as RealGridDataProviderFill | null;
|
||||||
|
if (dp?.fillJsonData) {
|
||||||
|
dp.fillJsonData(currentData, { fillMode: "set" });
|
||||||
|
}
|
||||||
|
gridRef.gridView.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그리드 데이터 설정 오류:", error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [rowDataMap, columnInfo]);
|
||||||
|
|
||||||
|
|
||||||
// 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때)
|
// 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gridRef = gridRefs.current.get(activeTab);
|
const gridRef = gridRefs.current.get(activeTab);
|
||||||
@@ -378,7 +691,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[60vh]">
|
<div className="h-[60vh]" data-tab-key={tabKey}>
|
||||||
<RealGridReact
|
<RealGridReact
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -392,6 +705,13 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
|
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
|
||||||
if (!currentInfo || !currentInfo.columns) return;
|
if (!currentInfo || !currentInfo.columns) return;
|
||||||
|
|
||||||
|
const columns = currentInfo.columns;
|
||||||
|
|
||||||
|
const dp = el.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined;
|
||||||
|
if (dp?.setFields) {
|
||||||
|
dp.setFields(buildFieldsFromColumns(columns));
|
||||||
|
}
|
||||||
|
|
||||||
gridView.setDisplayOptions({
|
gridView.setDisplayOptions({
|
||||||
fitStyle: GridFitStyle.EVEN
|
fitStyle: GridFitStyle.EVEN
|
||||||
});
|
});
|
||||||
@@ -404,24 +724,141 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
});
|
});
|
||||||
|
|
||||||
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
||||||
const hasFooter = currentInfo.columns.some(col => col.footer);
|
const hasFooter = columns.some(col => col.footer);
|
||||||
if (hasFooter) {
|
if (hasFooter) {
|
||||||
|
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
const hasMultiRowFooter = columns.some(col => {
|
||||||
|
const footer = col.footer;
|
||||||
|
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
|
||||||
|
});
|
||||||
|
if (hasArrayFooter || hasMultiRowFooter) {
|
||||||
|
// footer 배열의 길이를 확인하여 행 수 결정
|
||||||
|
const arrayFooterLengths = columns
|
||||||
|
.filter(col => col.footer && Array.isArray(col.footer))
|
||||||
|
.map(col => (col.footer as unknown[]).length);
|
||||||
|
const maxFooterLength = arrayFooterLengths.length > 0
|
||||||
|
? Math.max(...arrayFooterLengths)
|
||||||
|
: 2; // 기본값 2
|
||||||
|
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
|
||||||
|
gridView.setFooters?.(footerHeights);
|
||||||
|
} else {
|
||||||
gridView.footer.height = 40;
|
gridView.footer.height = 40;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gridView.setOptions({
|
gridView.setOptions({
|
||||||
summaryMode: "aggregate"
|
summaryMode: "aggregate"
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer의 valueCallback 설정
|
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||||
currentInfo.columns.forEach(col => {
|
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||||
if (col.footer && col.footer.valueCallback) {
|
return layout.some(item => {
|
||||||
const column = gridView.columnByName(col.name);
|
if (typeof item === 'string') return false;
|
||||||
if (column && column.footer) {
|
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||||
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||||
|
return checkFooterUserSpans(item.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFooterUserSpans(columnLayout)) {
|
||||||
|
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gridView && columnLayout) {
|
||||||
|
gridView.setColumnLayout?.(columnLayout);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||||
|
// setFooters 호출 후 footer 배열 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentGridView = gridView;
|
||||||
|
if (!currentGridView || !currentGridView.columnByName) {
|
||||||
|
console.warn('GridView not available for footer setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
if (hasArrayFooter) {
|
||||||
|
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||||
|
const columnsWithFooter: 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) {
|
||||||
|
if (Array.isArray(col.footer)) {
|
||||||
|
colInfo.footer = col.footer;
|
||||||
|
} else {
|
||||||
|
colInfo.footer = {
|
||||||
|
text: col.footer.text,
|
||||||
|
expression: col.footer.expression,
|
||||||
|
numberFormat: col.footer.numberFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||||
|
|
||||||
|
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
// footer 배열의 각 요소에 valueCallback 설정
|
||||||
|
col.footer.forEach((footerItem, index) => {
|
||||||
|
if (footerItem.valueCallback && column.footer) {
|
||||||
|
// footer가 배열인 경우
|
||||||
|
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||||
|
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set columns with footer array:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column || !column.footer) return;
|
||||||
|
|
||||||
|
const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown };
|
||||||
|
if (footerObj.valueCallback) {
|
||||||
|
(column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
// 고정 컬럼 설정
|
// 고정 컬럼 설정
|
||||||
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
|
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
|
||||||
@@ -430,60 +867,60 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// valueType이 설정된 컬럼에 대해 필드의 dataType을 명시적으로 설정
|
// 각 탭별 ref 초기화
|
||||||
// autoGenerateField={true}일 때 valueType prop만으로는 필드의 dataType이 설정되지 않을 수 있음
|
if (!sortDirRefs.current.has(tabKey)) {
|
||||||
|
sortDirRefs.current.set(tabKey, { current: {} });
|
||||||
|
}
|
||||||
|
if (!originalRowsRefs.current.has(tabKey)) {
|
||||||
|
originalRowsRefs.current.set(tabKey, { current: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortDirRef = sortDirRefs.current.get(tabKey)!;
|
||||||
|
const originalRowsRef = originalRowsRefs.current.get(tabKey)!;
|
||||||
|
|
||||||
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
|
||||||
|
const hasMergeCells = columns.some((col) => col.mergeCells === true);
|
||||||
|
|
||||||
|
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
|
||||||
if (el.dataProvider) {
|
if (el.dataProvider) {
|
||||||
const dataProvider = el.dataProvider;
|
const dp = el.dataProvider as RealGridDataProviderComparer;
|
||||||
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
|
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
|
||||||
const fieldMap = new Map<string, string>();
|
const numberComparer = createNumberComparer(dp);
|
||||||
|
const seen = new Set<string>();
|
||||||
currentInfo.columns.forEach(col => {
|
columns.forEach((col) => {
|
||||||
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
|
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
|
||||||
fieldMap.set(col.fieldName, col.valueType);
|
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
|
||||||
fieldsToSet.push({
|
seen.add(col.fieldName);
|
||||||
fieldName: col.fieldName,
|
|
||||||
dataType: col.valueType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
|
|
||||||
if (fieldsToSet.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
// 기존 필드 정보 가져오기
|
dp.setDataComparer!(col.fieldName, numberComparer);
|
||||||
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
|
} catch (e) {
|
||||||
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
|
console.warn("setDataComparer:", e);
|
||||||
|
}
|
||||||
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
|
|
||||||
const fieldsToUpdate = fieldsToSet.filter(field => {
|
|
||||||
const existingDataType = existingFieldMap.get(field.fieldName);
|
|
||||||
return existingDataType !== field.dataType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fieldsToUpdate.length > 0) {
|
|
||||||
// 기존 필드 정보를 유지하면서 dataType만 업데이트
|
|
||||||
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
|
|
||||||
const allFields = existingFields.map(f => ({
|
|
||||||
fieldName: f.fieldName,
|
|
||||||
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 새로 추가할 필드가 있으면 추가
|
|
||||||
fieldsToSet.forEach(field => {
|
|
||||||
if (!existingFieldMap.has(field.fieldName)) {
|
|
||||||
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// setFields를 호출하여 필드의 dataType 업데이트
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
|
||||||
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
|
const setRowData = (updater: React.SetStateAction<Record<string, string | number>[]>) => {
|
||||||
.setFields(allFields);
|
setRowDataMap(prev => {
|
||||||
}
|
const newMap = new Map(prev);
|
||||||
} catch (error) {
|
const currentData = newMap.get(tabKey) || [];
|
||||||
console.warn('Failed to set field dataType:', error);
|
const newData = typeof updater === 'function' ? updater(currentData) : updater;
|
||||||
}
|
newMap.set(tabKey, newData);
|
||||||
}
|
return newMap;
|
||||||
}
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그리드 DOM 요소 찾기 (나중에 화살표 업데이트에 사용)
|
||||||
|
// 탭별로 그리드 요소를 찾기 위해 data-tab-key 사용
|
||||||
|
const findGridElement = () => {
|
||||||
|
const tabContent = document.querySelector(`[data-tab-key="${tabKey}"]`);
|
||||||
|
return tabContent?.querySelector('.rg-grid, [data-grid-id]') as HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement);
|
||||||
|
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = onSortingHandler;
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -503,10 +940,11 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
|
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
|
||||||
header={column.header}
|
header={column.header}
|
||||||
editable={column.editable}
|
editable={column.editable}
|
||||||
footer={column.footer ? {
|
{...(column.visible === false && { visible: false })}
|
||||||
text: column.footer.text,
|
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||||
expression: column.footer.expression,
|
text: (column.footer as { text?: string }).text,
|
||||||
numberFormat: column.footer.numberFormat,
|
expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression,
|
||||||
|
numberFormat: (column.footer as { numberFormat?: string }).numberFormat,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
||||||
sortable={!column.sortdisable}
|
sortable={!column.sortdisable}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { DataGrid } from "@rsone/components";
|
import { DataGrid } from "@rsone/components";
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -49,7 +50,7 @@ const ExcretaTreatmentFacilityInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from "@rsone/components";
|
import { TabDataGrid } from "@rsone/components";
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -40,7 +41,7 @@ const PrivateSewageFacilityInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import SearchForm from '@/components/module/SearchForm';
|
import SearchForm from '@/components/module/SearchForm';
|
||||||
import { DataGrid } from '@rsone/components';
|
import { DataGrid } from '@rsone/components';
|
||||||
import SubmitButton from '@/components/button/SumitButton';
|
import SubmitButton from '@/components/button/SumitButton';
|
||||||
@@ -43,7 +44,7 @@ const PublicSewageFacilityInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { DataGrid } from '@rsone/components';
|
import { DataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -49,7 +50,7 @@ const PumpingStationInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from "@rsone/components";
|
import { TabDataGrid } from "@rsone/components";
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -53,7 +54,7 @@ const SewageExcretaRelatedInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from "@rsone/components";
|
import { TabDataGrid } from "@rsone/components";
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -55,7 +56,7 @@ const SewageExcretaSlurgeInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from '@rsone/components';
|
import { TabDataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -50,7 +51,7 @@ const SewagePipeInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from '@rsone/components';
|
import { TabDataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -42,7 +43,7 @@ const SewagePipeMaintainInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from '@rsone/components';
|
import { TabDataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -56,7 +57,7 @@ const SewageReservoirPumpInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from '@rsone/components';
|
import { TabDataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -42,7 +43,7 @@ const SewerageFinanceInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,16 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
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 SubmitButton from "@/components/button/SumitButton";
|
||||||
import SaveButton from "@/components/button/SaveButton";
|
import SaveButton from "@/components/button/SaveButton";
|
||||||
import LastYearDataButton from "@/components/button/LastYearDataButton";
|
import LastYearDataButton from "@/components/button/LastYearDataButton";
|
||||||
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
|
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
|
||||||
|
import { LoadingWithAnimation } from "@common/components";
|
||||||
|
|
||||||
import { useState } from "react";
|
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";
|
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. 하수도 보급률
|
// 하수도 통계 관리 > 01. 하수도 보급률
|
||||||
const SeweragePenetrationRateInp = () => {
|
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 = () => {
|
const SubmitExceptionFunc = () => {
|
||||||
// 예외 제출 로직
|
// 예외 제출 로직
|
||||||
@@ -28,11 +86,9 @@ const SeweragePenetrationRateInp = () => {
|
|||||||
// 저장 로직
|
// 저장 로직
|
||||||
};
|
};
|
||||||
|
|
||||||
const [rowData, setRowData] = useState<Record<string, string | number>[]>([]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative">
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm onSubmit={handleSearch} onReset={handleReset} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
||||||
@@ -41,6 +97,11 @@ const SeweragePenetrationRateInp = () => {
|
|||||||
<SaveButton saveFunc={saveFunc} />
|
<SaveButton saveFunc={saveFunc} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{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
|
<DataGrid
|
||||||
columnInfo={SeweragePenetrationRateInpColumn}
|
columnInfo={SeweragePenetrationRateInpColumn}
|
||||||
rowStartNum={3}
|
rowStartNum={3}
|
||||||
@@ -48,7 +109,7 @@ const SeweragePenetrationRateInp = () => {
|
|||||||
rowData={rowData}
|
rowData={rowData}
|
||||||
setRowData={setRowData}
|
setRowData={setRowData}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from '@rsone/components';
|
import { TabDataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -40,7 +41,7 @@ const SewerageServiceLevelInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { DataGrid } from "@rsone/components";
|
import { DataGrid } from "@rsone/components";
|
||||||
import ApproveButton from "@/components/button/ApproveButton";
|
import ApproveButton from "@/components/button/ApproveButton";
|
||||||
import RejectButton from "@/components/button/RejectButton";
|
import RejectButton from "@/components/button/RejectButton";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
|
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ const StatsInputStatusInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<RejectButton rejectFunc={rejectFunc} />
|
<RejectButton rejectFunc={rejectFunc} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import SearchForm from "@/components/module/SearchForm";
|
import SearchForm from "@/components/module/SearchForm";
|
||||||
import { TabDataGrid } from '@rsone/components';
|
import { TabDataGrid } from '@rsone/components';
|
||||||
import SubmitButton from "@/components/button/SumitButton";
|
import SubmitButton from "@/components/button/SumitButton";
|
||||||
@@ -62,7 +63,7 @@ const WaterReuseInp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const NotEnteredStatus = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<DataGrid columnInfo={{ columns, columnLayout }} rowData={rowData} setRowData={setRowData} isExcelImport={false} fixedColumns={2} rowStartNum={2} />
|
<DataGrid columnInfo={{ columns, columnLayout }} rowData={rowData} setRowData={setRowData} isExcelImport={false} fixedColumns={2} rowStartNum={2} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const StatisticsInputManagement = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} sgg={false} sido={false} />
|
<SearchForm sgg={false} sido={false} />
|
||||||
<DataGrid columnInfo={{ columns, columnLayout }} rowData={rowData} setRowData={setRowData} isExcelImport={false} isExcelExport={false} />
|
<DataGrid columnInfo={{ columns, columnLayout }} rowData={rowData} setRowData={setRowData} isExcelImport={false} isExcelExport={false} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const StatisticsPopulationManagement = () => {
|
const StatisticsPopulationManagement = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} sgg={false} sido={false} />
|
<SearchForm sgg={false} sido={false} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const StatisticsReviewHistory = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<DataGrid columnInfo={{ columns, columnLayout }} rowStartNum={2} fixedColumns={3} rowData={rowData} setRowData={setRowData} isExcelImport={false} />
|
<DataGrid columnInfo={{ columns, columnLayout }} rowStartNum={2} fixedColumns={3} rowData={rowData} setRowData={setRowData} isExcelImport={false} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const ExcretaTreatmentFacilityRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const PrivateSewageFacilityRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const PublicSewageFacilityRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const PumpingStationRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SewageExcretaRelatedRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SewageExcretaSlurgeRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import ReturnButton from "@/components/button/ReturnButton";
|
|||||||
|
|
||||||
import { SewagePipeMaintainColumnInfoList } from "@/components/util/column/review/SewagePipeMaintainRvwColumn";
|
import { SewagePipeMaintainColumnInfoList } from "@/components/util/column/review/SewagePipeMaintainRvwColumn";
|
||||||
|
|
||||||
|
|
||||||
// 하수도 통계 관리 03. 하수관로 유지 관리
|
// 하수도 통계 관리 03. 하수관로 유지 관리
|
||||||
const SewagePipeMaintainRvw = () => {
|
const SewagePipeMaintainRvw = () => {
|
||||||
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
|
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
|
||||||
@@ -36,7 +35,7 @@ const SewagePipeMaintainRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SewagePipeRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
@@ -44,7 +44,7 @@ const SewagePipeRvw = () => {
|
|||||||
<SaveButton saveFunc={saveFunc} />
|
<SaveButton saveFunc={saveFunc} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TabDataGrid columnInfo={SewagePipeColumnInfoListRvw} isExcelImport={false} rowDataMap={rowDataMap} setRowDataMap={setRowDataMap} />
|
<TabDataGrid columnInfo={SewagePipeColumnInfoListRvw} isExcelImport={true} rowDataMap={rowDataMap} setRowDataMap={setRowDataMap} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SewageReservoirPumpRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const SewerageFinanceRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
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 SearchForm from "@/components/module/SearchForm";
|
||||||
import SaveButton from "@/components/button/SaveButton";
|
import SaveButton from "@/components/button/SaveButton";
|
||||||
import ApproveButton from "@/components/button/ApproveButton";
|
import ApproveButton from "@/components/button/ApproveButton";
|
||||||
@@ -35,7 +36,7 @@ const SeweragePenetrationRateRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SewerageServiceLevelRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const StatsInputStatusRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const WaterReuseRvw = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} fctName={true} />
|
<SearchForm fctName={true} />
|
||||||
<div className="card !py-0">
|
<div className="card !py-0">
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const ExcretaTreatmentFacilityTot = () => {
|
const ExcretaTreatmentFacilityTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const PrivateSewageFacilityTot = () => {
|
const PrivateSewageFacilityTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const PublicSewageFacilityTot = () => {
|
const PublicSewageFacilityTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const PumpingStationTot = () => {
|
const PumpingStationTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const SewageExcretaRelatedTot = () => {
|
const SewageExcretaRelatedTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const SewageExcretaSlurgeTot = () => {
|
const SewageExcretaSlurgeTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const SewagePipeMaintainTot = () => {
|
const SewagePipeMaintainTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const SewagePipeTot = () => {
|
const SewagePipeTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const SewageReservoirPumpTot = () => {
|
const SewageReservoirPumpTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const SewerageFinanceTot = () => {
|
const SewerageFinanceTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const SeweragePenetrationRateTot = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
|
||||||
<SubmitButton submitFunc={submitFunc} />
|
<SubmitButton submitFunc={submitFunc} />
|
||||||
<SaveButton saveFunc={saveFunc} />
|
<SaveButton saveFunc={saveFunc} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "../../module/SearchForm";
|
|||||||
const SewerageServiceLevelTot = () => {
|
const SewerageServiceLevelTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "../../module/SearchForm";
|
|||||||
const WaterReuseTot = () => {
|
const WaterReuseTot = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
|
|||||||
const ManagementVerificationRule = () => {
|
const ManagementVerificationRule = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchForm userMenual={true} />
|
<SearchForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,34 @@ import React from "react";
|
|||||||
import { ValueType } from "realgrid";
|
import { ValueType } from "realgrid";
|
||||||
|
|
||||||
// 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 = {
|
export type GridView = {
|
||||||
getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined;
|
getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined;
|
||||||
getDataSource(): {
|
getDataSource(): {
|
||||||
@@ -25,13 +53,40 @@ export type ColumnFooter = {
|
|||||||
numberFormat?: string;
|
numberFormat?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */
|
||||||
|
export type SortPreserveGroupOrder = {
|
||||||
|
groupField: string;
|
||||||
|
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 = {
|
export type ColumnConfig = {
|
||||||
name: string;
|
name: string;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
header: { text: string };
|
header: { text: string };
|
||||||
|
/** 계산 필드: fields 합계/평균 등. valueCallback으로 dataProvider.setFields 시 전달 */
|
||||||
|
computed?: ComputedFieldConfig;
|
||||||
editable?: boolean; // 셀 편집 가능 여부
|
editable?: boolean; // 셀 편집 가능 여부
|
||||||
|
/** 그리드에서 컬럼 숨김 (수식용 필드 등) */
|
||||||
|
visible?: boolean;
|
||||||
mergeCells?: boolean;
|
mergeCells?: boolean;
|
||||||
sortdisable?: boolean;
|
sortdisable?: boolean;
|
||||||
|
sortPreserveGroupOrder?: SortPreserveGroupOrder;
|
||||||
valueType?: ValueType;
|
valueType?: ValueType;
|
||||||
dataFormat?: string;
|
dataFormat?: string;
|
||||||
footer?: {
|
footer?: {
|
||||||
@@ -39,7 +94,19 @@ export type ColumnConfig = {
|
|||||||
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||||
numberFormat?: string;
|
numberFormat?: string;
|
||||||
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||||
},
|
multiRow?: boolean; // 여러 행 footer 사용 여부
|
||||||
|
} | Array<{
|
||||||
|
text?: string;
|
||||||
|
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||||
|
numberFormat?: string;
|
||||||
|
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||||
|
}>, // RealGrid에서 footer를 배열로 설정할 수 있음
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FooterUserSpan = {
|
||||||
|
rowspan?: number; // 병합할 행 수
|
||||||
|
colspan?: number; // 병합할 컬럼 수
|
||||||
|
text?: string; // 표시할 텍스트
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ColumnLayoutItem = string | {
|
export type ColumnLayoutItem = string | {
|
||||||
@@ -47,6 +114,10 @@ export type ColumnLayoutItem = string | {
|
|||||||
hideChildHeaders?: boolean;
|
hideChildHeaders?: boolean;
|
||||||
header: { text: string };
|
header: { text: string };
|
||||||
items: (string | ColumnLayoutItem)[];
|
items: (string | ColumnLayoutItem)[];
|
||||||
|
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
|
||||||
|
} | {
|
||||||
|
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
|
||||||
|
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ColumnInfo = {
|
export type ColumnInfo = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type SearchFormValues = {
|
export type SearchFormValues = {
|
||||||
sido?: string;
|
sido?: string;
|
||||||
sgg?: string;
|
gugun?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
order?: string;
|
order?: string;
|
||||||
fctName?: string;
|
fctName?: string;
|
||||||
@@ -8,7 +8,7 @@ export type SearchFormValues = {
|
|||||||
|
|
||||||
export type SearchFormProps = {
|
export type SearchFormProps = {
|
||||||
sido?: boolean;
|
sido?: boolean;
|
||||||
sgg?: boolean;
|
gugun?: boolean;
|
||||||
year?: boolean;
|
year?: boolean;
|
||||||
order?: boolean;
|
order?: boolean;
|
||||||
fctName?: boolean;
|
fctName?: boolean;
|
||||||
@@ -20,7 +20,7 @@ export type SearchFormProps = {
|
|||||||
|
|
||||||
export const defaultSearchFormProps: Required<Omit<SearchFormProps, 'onSubmit' | 'onReset' | 'defaultValues'>> = {
|
export const defaultSearchFormProps: Required<Omit<SearchFormProps, 'onSubmit' | 'onReset' | 'defaultValues'>> = {
|
||||||
sido: true,
|
sido: true,
|
||||||
sgg: true,
|
gugun: true,
|
||||||
year: true,
|
year: true,
|
||||||
order: false,
|
order: false,
|
||||||
fctName: 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";
|
import { ValueType } from "realgrid";
|
||||||
/**
|
/**
|
||||||
* 하수도 통계 관리 > 01. 하수도 보급률
|
* 하수도 통계 관리 > 01. 하수도 보급률
|
||||||
@@ -7,74 +7,153 @@ import { ValueType } from "realgrid";
|
|||||||
export const SeweragePenetrationRateInpColumn = {
|
export const SeweragePenetrationRateInpColumn = {
|
||||||
columns: [
|
columns: [
|
||||||
// 행정구역
|
// 행정구역
|
||||||
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, editable: false },
|
{ name: 'sido', fieldName: 'sido', header: { text: '시도' }, footer: { text: '합계' }, editable: false },
|
||||||
{ name: 'sgg', fieldName: '시군구', 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: 'rvbsn', fieldName: 'rvbsn', header: { text: '수계' }, editable: false },
|
||||||
{ name: 'bstrm', fieldName: '지류', header: { text: '지류' }, editable: false },
|
{ name: 'bstrm', fieldName: 'bstrm', header: { text: '지류' }, editable: false },
|
||||||
|
|
||||||
// 수계영향권별분류
|
// 수계영향권별분류
|
||||||
{ name: 'mdbsn', fieldName: '세부단위유역', header: { text: '세부단위유역' }, editable: false },
|
{ name: 'mdbsn', fieldName: 'mdbsn', header: { text: '세부단위유역' }, editable: false },
|
||||||
{ name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' }, editable: false },
|
{ name: 'lgbsn', fieldName: 'lgbsn', header: { text: '중권역' }, editable: false },
|
||||||
{ name: 'mdmbsn', fieldName: '소권역', 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: 'totalPopl', fieldName: 'totalPopl', 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: 'poplSm', fieldName: 'poplSm', 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: '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' },
|
{ name: 'area', fieldName: 'area', 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: '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: 'pblswrgPopltnSm',
|
||||||
{ name: 'pblswrg_scd_trt', fieldName: '생물학적처리', header: { text: '생물학적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
fieldName: 'pblswrgPopltnSm',
|
||||||
{ name: 'pblswrg_tert_trt', fieldName: '고도처리', header: { text: '고도처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
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: 'wstrPopltnSm',
|
||||||
{ name: 'wstr_scd_trt', fieldName: '생물학적처리', header: { text: '생물학적처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
fieldName: 'wstrPopltnSm',
|
||||||
{ name: 'wstr_tert_trt', fieldName: '고도처리', header: { text: '고도처리' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
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: 'swgeProcZoneOuterSm',
|
||||||
{ name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
fieldName: 'swgeProcZoneOuterSm',
|
||||||
{ name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' }, footer: footerInt, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
header: { text: '합계' },
|
||||||
{ name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
|
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: 'pblswrg_popltn_suprt', fieldName: '공공하수처리구역인구보급률(%)', header: { text: '공공하수처리구역인구보급률(%)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
|
{
|
||||||
|
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: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급률(%)', header: { text: '고도처리인구보급률(%)' }, footer: footerDecimal1, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' },
|
{
|
||||||
|
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: [
|
columnLayout: [
|
||||||
@@ -82,7 +161,11 @@ export const SeweragePenetrationRateInpColumn = {
|
|||||||
name: 'groupA',
|
name: 'groupA',
|
||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '행정구역' },
|
header: { text: '행정구역' },
|
||||||
items: ['sido', 'sgg'],
|
items: [
|
||||||
|
{ column: 'sido', footerUserSpans: [{ colspan: 3 }] },
|
||||||
|
'gugun',
|
||||||
|
'adstDong',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'rvbsn',
|
'rvbsn',
|
||||||
'bstrm',
|
'bstrm',
|
||||||
@@ -92,36 +175,36 @@ export const SeweragePenetrationRateInpColumn = {
|
|||||||
header: { text: '수계영향권별분류' },
|
header: { text: '수계영향권별분류' },
|
||||||
items: ['mdbsn', 'lgbsn', 'mdmbsn'],
|
items: ['mdbsn', 'lgbsn', 'mdmbsn'],
|
||||||
},
|
},
|
||||||
'specl_cntrpln_ar',
|
'speclCntrplnAr',
|
||||||
{
|
{
|
||||||
name: 'groupC',
|
name: 'groupC',
|
||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '행정참고자료인구' },
|
header: { text: '행정참고자료인구' },
|
||||||
items: ['total_popl', 'popl_sm', 'frgnr_popl_sm'],
|
items: ['totalPopl', 'poplSm', 'frgnrPoplSm'],
|
||||||
},
|
},
|
||||||
'area',
|
'area',
|
||||||
'popltn_sm',
|
'popltnSm',
|
||||||
'ar_sm',
|
'arSm',
|
||||||
{
|
{
|
||||||
name: 'groupD',
|
name: 'groupD',
|
||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '하수처리구역내' },
|
header: { text: '하수처리구역내' },
|
||||||
items: [
|
items: [
|
||||||
'swge_proc_zone_inner_sm',
|
'swgeProcZoneInnerSm',
|
||||||
{
|
{
|
||||||
name: 'pblswrgGroup',
|
name: 'pblswrgGroup',
|
||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '공공하수처리인구(명)' },
|
header: { text: '공공하수처리인구(명)' },
|
||||||
items: ['pblswrg_popltn_sm', 'pblswrg_prm_trt', 'pblswrg_scd_trt', 'pblswrg_tert_trt'],
|
items: ['pblswrgPopltnSm', 'pblswrgPrmTrt', 'pblswrgScdTrt', 'pblswrgTertTrt'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'wstrGroup',
|
name: 'wstrGroup',
|
||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '폐수처리인구(명)' },
|
header: { text: '폐수처리인구(명)' },
|
||||||
items: ['wstr_popltn_sm', 'wstr_prm_trt', 'wstr_scd_trt', 'wstr_tert_trt'],
|
items: ['wstrPopltnSm', 'wstrPrmTrt', 'wstrScdTrt', 'wstrTertTrt'],
|
||||||
},
|
},
|
||||||
'not_conect_popltn',
|
'notConectPopltn',
|
||||||
'swge_proc_zone_inner_ar'
|
'swgeProcZoneInnerAr'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -129,16 +212,17 @@ export const SeweragePenetrationRateInpColumn = {
|
|||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '하수처리구역외' },
|
header: { text: '하수처리구역외' },
|
||||||
items: [
|
items: [
|
||||||
'swge_proc_zone_outer_sm',
|
'swgeProcZoneOuterSm',
|
||||||
'town_unproc_popltn',
|
'townUnprocPopltn',
|
||||||
'town_swtt_popltn',
|
'townSwttPopltn',
|
||||||
'town_wrrtn_popltn',
|
'townWrrtnPopltn',
|
||||||
'swge_proc_zone_outer_ar'
|
'swgeProcZoneOuterAr'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'pblswrg_popltn_suprt',
|
'pblswrgPopltnSuprt',
|
||||||
'adtrt_popltn_suprt',
|
'adtrtPopltnSuprt',
|
||||||
'swrg_instlrt',
|
'swrgInstlrt',
|
||||||
'rmk',
|
'rmk',
|
||||||
|
'piplSupprt',
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -6,13 +6,13 @@ const rawList = [
|
|||||||
{
|
{
|
||||||
tabKey: 'a',
|
tabKey: 'a',
|
||||||
tabLabel: '지역별 시설현황',
|
tabLabel: '지역별 시설현황',
|
||||||
rowStartNum: 2,
|
rowStartNum: 4,
|
||||||
fixedColumns: 1,
|
fixedColumns: 2,
|
||||||
columns: [
|
columns: [
|
||||||
/* 기본 정보 */
|
/* 기본 정보 */
|
||||||
{ name: 'SIDO', fieldName: '시도', header: { text: '시도' } },
|
{ name: 'SIDO', fieldName: '시도', header: { text: '시도' }, mergeCells: true },
|
||||||
{ name: 'GUGUN', fieldName: '시군구', header: { text: '시군구' } },
|
{ name: 'GUGUN', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true },
|
||||||
|
{ name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true },
|
||||||
/* 하수정비 기본계획 */
|
/* 하수정비 기본계획 */
|
||||||
{ name: 'TARGET_YR', fieldName: '하수정보기본계획목표년도', header: { text: '하수정보기본계획목표년도' } },
|
{ name: 'TARGET_YR', fieldName: '하수정보기본계획목표년도', header: { text: '하수정보기본계획목표년도' } },
|
||||||
{ name: 'PIPL_IMPRMN_PLAN_EXTN', fieldName: '총관로정비 계획연장(m)', header: { text: '총관로정비\n계획연장(m)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.00' },
|
{ name: 'PIPL_IMPRMN_PLAN_EXTN', fieldName: '총관로정비 계획연장(m)', header: { text: '총관로정비\n계획연장(m)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.00' },
|
||||||
@@ -64,6 +64,7 @@ const rawList = [
|
|||||||
],
|
],
|
||||||
columnLayout: [
|
columnLayout: [
|
||||||
{ name: 'areaGroup', header: { text: '행정구역' }, items: ['SIDO', 'GUGUN'] },
|
{ name: 'areaGroup', header: { text: '행정구역' }, items: ['SIDO', 'GUGUN'] },
|
||||||
|
'구분',
|
||||||
'TARGET_YR', 'PIPL_IMPRMN_PLAN_EXTN', 'PIPL_PLAN_EXTN', 'PIPL_FCLTY_EXTN', 'PIPL_SUPPRT',
|
'TARGET_YR', 'PIPL_IMPRMN_PLAN_EXTN', 'PIPL_PLAN_EXTN', 'PIPL_FCLTY_EXTN', 'PIPL_SUPPRT',
|
||||||
{
|
{
|
||||||
name: 'combinedGroup', header: { text: '합류식(m)' },
|
name: 'combinedGroup', header: { text: '합류식(m)' },
|
||||||
|
|||||||
@@ -1,54 +1,185 @@
|
|||||||
import { ValueType } from "realgrid";
|
import { ValueType } from "realgrid";
|
||||||
import type { ColumnConfig } from "@rsone/components";
|
import { footerIntArrayByDivision, footerDecimal3ArrayByDivision } from "@/components/util/grid/footerSummary";
|
||||||
|
import type { GridView, DataColumn, ColumnFooter } from "@/components/type/DataGrid.type";
|
||||||
|
|
||||||
// 하수도 통계 관리 01. 하수도 보급률
|
// 하수도 통계 관리 01. 하수도 보급률
|
||||||
export const SeweragePenetrationRateInfoList = {
|
export const SeweragePenetrationRateInfoList = {
|
||||||
columns: [
|
columns: [
|
||||||
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true },
|
{
|
||||||
|
name: 'sido',
|
||||||
|
fieldName: '시도',
|
||||||
|
header: { text: '시도' },
|
||||||
|
mergeCells: true,
|
||||||
|
editable: false,
|
||||||
|
sortdisable: true,
|
||||||
|
footer: [
|
||||||
|
{
|
||||||
|
valueCallback: (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
footerIndex: number,
|
||||||
|
columnFooter: ColumnFooter,
|
||||||
|
value: number | string | undefined
|
||||||
|
) => {
|
||||||
|
// 첫 번째 footer 행: 첫 번째 "구분" 값 + "합계"
|
||||||
|
const divisionIndex = 0;
|
||||||
|
const dataSource = grid.getDataSource();
|
||||||
|
const rowCount = dataSource.getRowCount();
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const divisionValue = dataSource.getValue(i, '구분');
|
||||||
|
if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') {
|
||||||
|
uniqueValues.add(String(divisionValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisionValues = Array.from(uniqueValues);
|
||||||
|
if (divisionValues.length > divisionIndex) {
|
||||||
|
return divisionValues[divisionIndex] + '합계';
|
||||||
|
}
|
||||||
|
return '합계';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
valueCallback: (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
footerIndex: number,
|
||||||
|
columnFooter: ColumnFooter,
|
||||||
|
value: number | string | undefined
|
||||||
|
) => {
|
||||||
|
// 두 번째 footer 행: 두 번째 "구분" 값 + "합계"
|
||||||
|
const divisionIndex = 1;
|
||||||
|
const dataSource = grid.getDataSource();
|
||||||
|
const rowCount = dataSource.getRowCount();
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const divisionValue = dataSource.getValue(i, '구분');
|
||||||
|
if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') {
|
||||||
|
uniqueValues.add(String(divisionValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisionValues = Array.from(uniqueValues);
|
||||||
|
if (divisionValues.length > divisionIndex) {
|
||||||
|
return divisionValues[divisionIndex] + '합계';
|
||||||
|
}
|
||||||
|
return '합계';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{ name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true },
|
{ name: 'sgg', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true, editable: false, sortdisable: true },
|
||||||
{ name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true },
|
{
|
||||||
|
name: '구분',
|
||||||
|
fieldName: '구분',
|
||||||
|
header: { text: '구분' },
|
||||||
|
editable: false,
|
||||||
|
sortdisable: true,
|
||||||
|
footer: [
|
||||||
|
{
|
||||||
|
valueCallback: (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
footerIndex: number,
|
||||||
|
columnFooter: ColumnFooter,
|
||||||
|
value: number | string | undefined
|
||||||
|
) => {
|
||||||
|
// 첫 번째 footer 행: 첫 번째 "구분" 값 사용 (footerIndex는 0이어야 함)
|
||||||
|
const divisionIndex = 0; // 첫 번째 footer 요소는 항상 첫 번째 "구분" 값 사용
|
||||||
|
const dataSource = grid.getDataSource();
|
||||||
|
const rowCount = dataSource.getRowCount();
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const divisionValue = dataSource.getValue(i, '구분');
|
||||||
|
if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') {
|
||||||
|
uniqueValues.add(String(divisionValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisionValues = Array.from(uniqueValues);
|
||||||
|
if (divisionValues.length > divisionIndex) {
|
||||||
|
const result = divisionValues[divisionIndex] + '합계';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
valueCallback: (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
footerIndex: number,
|
||||||
|
columnFooter: ColumnFooter,
|
||||||
|
value: number | string | undefined
|
||||||
|
) => {
|
||||||
|
// 두 번째 footer 행: 두 번째 "구분" 값 사용 (footerIndex는 1이어야 함)
|
||||||
|
const divisionIndex = 1; // 두 번째 footer 요소는 항상 두 번째 "구분" 값 사용
|
||||||
|
const dataSource = grid.getDataSource();
|
||||||
|
const rowCount = dataSource.getRowCount();
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const divisionValue = dataSource.getValue(i, '구분');
|
||||||
|
if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') {
|
||||||
|
uniqueValues.add(String(divisionValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisionValues = Array.from(uniqueValues);
|
||||||
|
if (divisionValues.length > divisionIndex) {
|
||||||
|
const result = divisionValues[divisionIndex] + '합계';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{ name: '수계', fieldName: '수계', header: { text: '수계' } },
|
{ name: '수계', fieldName: '수계', header: { text: '수계' } },
|
||||||
{ name: 'bstrm', fieldName: '지류', header: { text: '지류' } },
|
{ name: 'bstrm', fieldName: '지류', header: { text: '지류' } },
|
||||||
{ name: 'mdbsn', fieldName: '세부단위유역', header: { text: '세부단위유역' } },
|
{ name: 'mdbsn', fieldName: '세부단위유역', header: { text: '세부단위유역' } },
|
||||||
{ name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' } },
|
{ name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' } },
|
||||||
{ name: 'mdmbsn', fieldName: '소권역', header: { text: '소권역' } },
|
{ name: 'mdmbsn', fieldName: '소권역', header: { text: '소권역' } },
|
||||||
{ name: 'specl_cntrpln_ar', fieldName: '특별대책지역', header: { text: '특별대책지역' } },
|
{ name: 'specl_cntrpln_ar', fieldName: '특별대책지역', header: { text: '특별대책지역' } },
|
||||||
{ name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
{ name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, footer: footerIntArrayByDivision, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: 'popl_sm', fieldName: '내국인', header: { text: '내국인' } },
|
{ name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
|
||||||
{ name: 'frgnr_popl_sm', fieldName: '외국인', header: { text: '외국인' } },
|
{ name: '계', fieldName: '계', header: { text: '계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: 'area', fieldName: '행정참고자료면적(㎢)', header: { text: '행정참고자료면적(㎢)' } },
|
|
||||||
{ name: 'popltn_sm', fieldName: '총인구(명)', header: { text: '총인구(명)' } },
|
|
||||||
{ name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, valueType: ValueType.NUMBER },
|
|
||||||
{ name: '계', fieldName: '계', header: { text: '계' } },
|
|
||||||
//공공하수처리인구
|
//공공하수처리인구
|
||||||
{ name: '합계', fieldName: '합계', header: { text: '합계' } },
|
{ name: '합계', fieldName: '합계', header: { text: '합계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: '1차처리', fieldName: '1차처리', header: { text: '1차처리' } },
|
{ name: '1차처리', fieldName: '1차처리', header: { text: '1차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: '2차처리', fieldName: '2차처리', header: { text: '2차처리' } },
|
{ name: '2차처리', fieldName: '2차처리', header: { text: '2차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: '3차처리', fieldName: '3차처리', header: { text: '3차처리' } },
|
{ name: '3차처리', fieldName: '3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
//폐수처리인구(명)
|
//폐수처리인구(명)
|
||||||
{ name: '(폐수처리인구)합계', fieldName: '(폐수처리인구)합계', header: { text: '합계' } },
|
{ name: '(폐수처리인구)합계', fieldName: '(폐수처리인구)합계', header: { text: '합계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: '(폐수처리인구)1차처리', fieldName: '(폐수처리인구)1차처리', header: { text: '1차처리' } },
|
{ name: '(폐수처리인구)1차처리', fieldName: '(폐수처리인구)1차처리', header: { text: '1차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: '(폐수처리인구)2차처리', fieldName: '(폐수처리인구)2차처리', header: { text: '2차처리' } },
|
{ name: '(폐수처리인구)2차처리', fieldName: '(폐수처리인구)2차처리', header: { text: '2차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' } },
|
{ name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
|
|
||||||
{ name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' } },
|
{ name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: 'swge_proc_zone_inner_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' } },
|
{ 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: '합계', header: { text: '계' } },
|
{ 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: '미처리인구' } },
|
{ name: 'town_unproc_popltn', fieldName: '미처리인구', header: { text: '미처리인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' } },
|
{ name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' } },
|
{ name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
|
||||||
{ name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' } },
|
{ 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: 'pblswrg_popltn_suprt', fieldName: '공공하수처리구역인구보급율(%)', header: { text: '공공하수처리구역인구보급율(%)' } },
|
||||||
{ name: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급율(%)', header: { text: `고도처리인구보급율(%)` } },
|
{ name: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급율(%)', header: { text: `고도처리인구보급율(%)` } },
|
||||||
{ name: 'swrg_instlrt', fieldName: '하수도설치율(%)', header: { text: '하수도설치율(%)' } },
|
{ name: 'swrg_instlrt', fieldName: '하수도설치율(%)', header: { text: '하수도설치율(%)' } },
|
||||||
{ name: 'rmk', fieldName: '비고', header: { text: '비고' } },
|
{ name: 'rmk', fieldName: '비고', header: { text: '비고' } },
|
||||||
] as ColumnConfig[],
|
],
|
||||||
columnLayout: [
|
columnLayout: [
|
||||||
{
|
{
|
||||||
name: 'groupA',
|
name: 'groupA',
|
||||||
hideChildHeaders: false,
|
hideChildHeaders: false,
|
||||||
header: { text: '행정구역' },
|
header: { text: '행정구역' },
|
||||||
items: ['sido', 'sgg'],
|
items: [
|
||||||
|
{ column: 'sido', footerUserSpans: [{ colspan: 3 }, { colspan: 3 }] }, // 첫 번째 footer 행과 두 번째 footer 행 모두 병합
|
||||||
|
'sgg',
|
||||||
|
]
|
||||||
},
|
},
|
||||||
'구분',
|
'구분',
|
||||||
'수계',
|
'수계',
|
||||||
@@ -60,7 +191,7 @@ export const SeweragePenetrationRateInfoList = {
|
|||||||
items: ['mdbsn', 'lgbsn', 'mdmbsn'],
|
items: ['mdbsn', 'lgbsn', 'mdmbsn'],
|
||||||
},
|
},
|
||||||
'specl_cntrpln_ar',
|
'specl_cntrpln_ar',
|
||||||
'popltn_sm',
|
'total_popl',
|
||||||
'ar_sm',
|
'ar_sm',
|
||||||
{
|
{
|
||||||
name: 'groupD',
|
name: 'groupD',
|
||||||
|
|||||||
@@ -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,90 @@ export const getFooterSummary = (grid: GridView, column: DataColumn) => {
|
|||||||
return sum;
|
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();
|
||||||
|
const rowCount = dataSource.getRowCount();
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const divisionValue = dataSource.getValue(i, divisionFieldName);
|
||||||
|
if (divisionValue !== null && divisionValue !== undefined && divisionValue !== '') {
|
||||||
|
uniqueValues.add(String(divisionValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set을 배열로 변환하고 순서 유지
|
||||||
|
return Array.from(uniqueValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// "구분" 필드 값에 따라 필터링하여 합계 계산하는 함수
|
||||||
|
// divisionIndex: 0 = 첫 번째 고유값, 1 = 두 번째 고유값
|
||||||
|
export const getFooterSummaryByDivision = (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
divisionIndex: number,
|
||||||
|
divisionFieldName: string = '구분'
|
||||||
|
) => {
|
||||||
|
const dataSource = grid.getDataSource();
|
||||||
|
const rowCount = dataSource.getRowCount();
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
// "구분" 필드의 고유값 찾기
|
||||||
|
const divisionValues = getDivisionValues(grid, divisionFieldName);
|
||||||
|
if (divisionValues.length <= divisionIndex) {
|
||||||
|
return 0; // 해당 인덱스의 고유값이 없으면 0 반환
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDivision = divisionValues[divisionIndex];
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
// "구분" 필드 값 확인
|
||||||
|
const divisionValue = dataSource.getValue(i, divisionFieldName);
|
||||||
|
if (String(divisionValue) !== targetDivision) {
|
||||||
|
continue; // 해당 "구분" 값이 아니면 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellValue = dataSource.getValue(i, column.fieldName);
|
||||||
|
|
||||||
|
// 문자열인 경우 숫자로 변환
|
||||||
|
const numericValue = typeof cellValue === 'string'
|
||||||
|
? Number(String(cellValue).replace(/,/g, ''))
|
||||||
|
: Number(cellValue);
|
||||||
|
|
||||||
|
if (!isNaN(numericValue) && numericValue !== null && numericValue !== undefined) {
|
||||||
|
sum += numericValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum;
|
||||||
|
};
|
||||||
|
|
||||||
// Footer 설정 객체 생성 함수
|
// Footer 설정 객체 생성 함수
|
||||||
const createSumFooter = (format: string) => ({
|
const createSumFooter = (format: string) => ({
|
||||||
expression: 'sum' as const,
|
expression: 'sum' as const,
|
||||||
@@ -32,6 +116,46 @@ 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) => [
|
||||||
|
{
|
||||||
|
numberFormat: format,
|
||||||
|
valueCallback: (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
footerIndex: number,
|
||||||
|
columnFooter: unknown,
|
||||||
|
value: number | string | undefined
|
||||||
|
) => {
|
||||||
|
// footerIndex: 0 = 첫 번째 footer 행 (첫 번째 "구분" 값), 1 = 두 번째 footer 행 (두 번째 "구분" 값)
|
||||||
|
// footerIndex를 divisionIndex로 사용
|
||||||
|
return getFooterSummaryByDivision(grid, column, footerIndex);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numberFormat: format,
|
||||||
|
valueCallback: (
|
||||||
|
grid: GridView,
|
||||||
|
column: DataColumn,
|
||||||
|
footerIndex: number,
|
||||||
|
columnFooter: unknown,
|
||||||
|
value: number | string | undefined
|
||||||
|
) => {
|
||||||
|
// footerIndex: 0 = 첫 번째 footer 행 (첫 번째 "구분" 값), 1 = 두 번째 footer 행 (두 번째 "구분" 값)
|
||||||
|
// footerIndex를 divisionIndex로 사용
|
||||||
|
return getFooterSummaryByDivision(grid, column, footerIndex);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 1. 정수형 : #,##0
|
// 1. 정수형 : #,##0
|
||||||
export const footerInt = createSumFooter('#,##0');
|
export const footerInt = createSumFooter('#,##0');
|
||||||
|
|
||||||
@@ -43,3 +167,12 @@ export const footerDecimal2 = createSumFooter('#,##0.00');
|
|||||||
|
|
||||||
// 4. 소수점 3자리 : #,##0.000
|
// 4. 소수점 3자리 : #,##0.000
|
||||||
export const footerDecimal3 = createSumFooter('#,##0.000');
|
export const footerDecimal3 = createSumFooter('#,##0.000');
|
||||||
|
|
||||||
|
// "구분" 값에 따라 필터링하는 Footer 배열 (정수형, 2줄)
|
||||||
|
export const footerIntArrayByDivision = createSumFooterArrayByDivision('#,##0');
|
||||||
|
|
||||||
|
// "구분" 값에 따라 필터링하는 Footer 배열 (소수점 3자리, 2줄)
|
||||||
|
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> {
|
export interface ApiResponse<T> {
|
||||||
status: string;
|
status: string;
|
||||||
data: T;
|
data: T;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatisticsService {
|
export interface StatisticsService {
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export const ENDPOINT = {
|
|||||||
POST_EVALUATION: "/board-service/api/v1/pstEvl",
|
POST_EVALUATION: "/board-service/api/v1/pstEvl",
|
||||||
},
|
},
|
||||||
ST_SERVICE: {
|
ST_SERVICE: {
|
||||||
COMMON: "/st-management-service/api/v1/common",
|
COMMON: "/st-input-service/api/v1/common",
|
||||||
|
INPUT: "/st-input-service/api/v1/input",
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,6 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef, useEffect } from "react"
|
import { useRef, useEffect } from "react";
|
||||||
import { ValueType } from "realgrid"
|
import { ValueType } from "realgrid";
|
||||||
|
|
||||||
import RealGrid, { GridFitStyle } from "realgrid"
|
import RealGrid, { GridFitStyle } from "realgrid";
|
||||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
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";
|
||||||
|
|
||||||
// RealGrid 타입 확장
|
// RealGrid 타입 확장
|
||||||
declare module "realgrid" {
|
declare module "realgrid" {
|
||||||
@@ -18,6 +19,49 @@ declare module "realgrid" {
|
|||||||
|
|
||||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||||
|
|
||||||
|
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||||
|
interface RealGridDataProviderComparer {
|
||||||
|
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||||
|
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) => {
|
||||||
|
const get = dp.getValue?.bind(dp);
|
||||||
|
if (!get) return 0;
|
||||||
|
const val1 = get(row1, field);
|
||||||
|
const val2 = get(row2, field);
|
||||||
|
if (val1 == null && val2 == null) return 0;
|
||||||
|
if (val1 == null) return 1;
|
||||||
|
if (val2 == null) return -1;
|
||||||
|
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||||
|
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||||
|
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||||
|
if (isNaN(num1)) return 1;
|
||||||
|
if (isNaN(num2)) return -1;
|
||||||
|
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
||||||
const { columns, columnLayout } = columnInfo;
|
const { columns, columnLayout } = columnInfo;
|
||||||
const gridRef = useRef<RealGridReact>(null);
|
const gridRef = useRef<RealGridReact>(null);
|
||||||
@@ -28,54 +72,21 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
const gridView = gridRef.current.gridView;
|
const gridView = gridRef.current.gridView;
|
||||||
const dataProvider = gridRef.current.dataProvider;
|
const dataProvider = gridRef.current.dataProvider;
|
||||||
|
|
||||||
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
|
// setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음)
|
||||||
const fieldMap = new Map<string, string>();
|
const dp = dataProvider as RealGridDataProviderComparer;
|
||||||
|
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
|
||||||
columns.forEach(col => {
|
const numberComparer = createNumberComparer(dp);
|
||||||
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
|
const seen = new Set<string>();
|
||||||
fieldMap.set(col.fieldName, col.valueType);
|
columns.forEach((col) => {
|
||||||
fieldsToSet.push({
|
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
|
||||||
fieldName: col.fieldName,
|
seen.add(col.fieldName);
|
||||||
dataType: col.valueType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
|
|
||||||
if (fieldsToSet.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
// 기존 필드 정보 가져오기
|
dp.setDataComparer!(col.fieldName, numberComparer);
|
||||||
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
|
} catch (e) {
|
||||||
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
|
console.warn("setDataComparer:", e);
|
||||||
|
}
|
||||||
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
|
|
||||||
const fieldsToUpdate = fieldsToSet.filter(field => {
|
|
||||||
const existingDataType = existingFieldMap.get(field.fieldName);
|
|
||||||
return existingDataType !== field.dataType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fieldsToUpdate.length > 0) {
|
|
||||||
// 기존 필드 정보를 유지하면서 dataType만 업데이트
|
|
||||||
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
|
|
||||||
const allFields = existingFields.map(f => ({
|
|
||||||
fieldName: f.fieldName,
|
|
||||||
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 새로 추가할 필드가 있으면 추가
|
|
||||||
fieldsToSet.forEach(field => {
|
|
||||||
if (!existingFieldMap.has(field.fieldName)) {
|
|
||||||
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// setFields를 호출하여 필드의 dataType 업데이트
|
|
||||||
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
|
|
||||||
.setFields(allFields);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to set field dataType:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gridView.setDisplayOptions({
|
gridView.setDisplayOptions({
|
||||||
@@ -87,13 +98,32 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
// 헤더 체크박스 표시 여부 설정
|
// 헤더 체크박스 표시 여부 설정
|
||||||
gridView.setOptions({
|
gridView.setOptions({
|
||||||
checkBar: { visible: showHeaderCheckbox },
|
checkBar: { visible: showHeaderCheckbox },
|
||||||
|
rowIndicator: { visible: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
||||||
const hasFooter = columns.some(col => col.footer);
|
const hasFooter = columns.some(col => col.footer);
|
||||||
if (hasFooter) {
|
if (hasFooter) {
|
||||||
|
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
const hasMultiRowFooter = columns.some(col => {
|
||||||
|
const footer = col.footer;
|
||||||
|
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
|
||||||
|
});
|
||||||
|
if (hasArrayFooter || hasMultiRowFooter) {
|
||||||
|
// footer 배열의 길이를 확인하여 행 수 결정
|
||||||
|
const arrayFooterLengths = columns
|
||||||
|
.filter(col => col.footer && Array.isArray(col.footer))
|
||||||
|
.map(col => (col.footer as unknown[]).length);
|
||||||
|
const maxFooterLength = arrayFooterLengths.length > 0
|
||||||
|
? Math.max(...arrayFooterLengths)
|
||||||
|
: 2; // 기본값 2
|
||||||
|
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
|
||||||
|
gridView.setFooters?.(footerHeights);
|
||||||
|
} else {
|
||||||
gridView.footer.height = 40;
|
gridView.footer.height = 40;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 고정 컬럼 설정
|
// 고정 컬럼 설정
|
||||||
if (fixedColumns !== undefined && fixedColumns > 0) {
|
if (fixedColumns !== undefined && fixedColumns > 0) {
|
||||||
@@ -106,15 +136,113 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
summaryMode: "aggregate"
|
summaryMode: "aggregate"
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer의 valueCallback 설정
|
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||||
|
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||||
|
return layout.some(item => {
|
||||||
|
if (typeof item === 'string') return false;
|
||||||
|
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||||
|
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||||
|
return checkFooterUserSpans(item.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFooterUserSpans(columnLayout)) {
|
||||||
|
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gridRef.current?.gridView && columnLayout) {
|
||||||
|
gridRef.current.gridView.setColumnLayout?.(columnLayout);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||||
|
// setFooters 호출 후 footer 배열 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentGridView = gridRef.current?.gridView;
|
||||||
|
if (!currentGridView || !currentGridView.columnByName) {
|
||||||
|
console.warn('GridView not available for footer setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
if (hasArrayFooter) {
|
||||||
|
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||||
|
const columnsWithFooter: 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) {
|
||||||
|
if (Array.isArray(col.footer)) {
|
||||||
|
colInfo.footer = col.footer;
|
||||||
|
} else {
|
||||||
|
colInfo.footer = {
|
||||||
|
text: col.footer.text,
|
||||||
|
expression: col.footer.expression,
|
||||||
|
numberFormat: col.footer.numberFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||||
|
|
||||||
|
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||||
|
setTimeout(() => {
|
||||||
columns.forEach(col => {
|
columns.forEach(col => {
|
||||||
if (col.footer && col.footer.valueCallback) {
|
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||||
const column = gridView.columnByName(col.name);
|
|
||||||
if (column && column.footer) {
|
try {
|
||||||
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
// footer 배열의 각 요소에 valueCallback 설정
|
||||||
|
col.footer.forEach((footerItem, index) => {
|
||||||
|
if (footerItem.valueCallback && column.footer) {
|
||||||
|
// footer가 배열인 경우
|
||||||
|
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||||
|
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set columns with footer array:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column || !column.footer) return;
|
||||||
|
|
||||||
|
if (col.footer.valueCallback) {
|
||||||
|
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
||||||
|
|
||||||
@@ -126,6 +254,12 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
autoGenerateField={true}
|
autoGenerateField={true}
|
||||||
layout={columnLayout}
|
layout={columnLayout}
|
||||||
rows={rowData}
|
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) => (
|
{columns.map((column) => (
|
||||||
<RGDataColumn
|
<RGDataColumn
|
||||||
@@ -136,7 +270,8 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
|
|||||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
||||||
header={column.header}
|
header={column.header}
|
||||||
editable={column.editable}
|
editable={column.editable}
|
||||||
footer={column.footer ? {
|
{...(column.visible === false && { visible: false })}
|
||||||
|
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||||
text: column.footer.text,
|
text: column.footer.text,
|
||||||
expression: column.footer.expression,
|
expression: column.footer.expression,
|
||||||
numberFormat: column.footer.numberFormat,
|
numberFormat: column.footer.numberFormat,
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { ValueType } from "realgrid"
|
|||||||
|
|
||||||
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"
|
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"
|
||||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
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 JSZip from "jszip"
|
||||||
import * as XLSX from "xlsx"
|
import * as XLSX from "xlsx"
|
||||||
import { Button } from "@common/components";
|
|
||||||
import { useAlertStore } from "../store/alert-store";
|
import { useAlertStore } from "../store/alert-store";
|
||||||
|
|
||||||
|
import { Button } from "@common/components";
|
||||||
import { useTabStore } from "../store/tab-store";
|
import { useTabStore } from "../store/tab-store";
|
||||||
|
|
||||||
// RealGrid 타입 확장
|
// RealGrid 타입 확장
|
||||||
@@ -35,10 +38,263 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||||
|
|
||||||
|
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||||
|
interface RealGridDataProviderComparer {
|
||||||
|
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||||
|
getValue?(row: number, field: string | number): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
|
||||||
|
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
|
||||||
|
return (field, row1, row2) => {
|
||||||
|
const get = dp.getValue?.bind(dp);
|
||||||
|
if (!get) return 0;
|
||||||
|
const val1 = get(row1, field);
|
||||||
|
const val2 = get(row2, field);
|
||||||
|
if (val1 == null && val2 == null) return 0;
|
||||||
|
if (val1 == null) return 1;
|
||||||
|
if (val2 == null) return -1;
|
||||||
|
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||||
|
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||||
|
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||||
|
if (isNaN(num1)) return 1;
|
||||||
|
if (isNaN(num2)) return -1;
|
||||||
|
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
|
||||||
|
function toNum(v: unknown): number {
|
||||||
|
if (v == null) return NaN;
|
||||||
|
if (typeof v === "number") return isNaN(v) ? NaN : v;
|
||||||
|
return parseFloat(String(v).replace(/,/g, "")) || NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
|
||||||
|
function updateHeaderSortIndicator(
|
||||||
|
gridView: {
|
||||||
|
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
},
|
||||||
|
columns: { fieldName?: string; name: string }[],
|
||||||
|
sortDir: Record<string, "asc" | "desc" | "none">
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
columns.forEach((col) => {
|
||||||
|
if (!col.fieldName && !col.name) return;
|
||||||
|
|
||||||
|
// 컬럼 찾기
|
||||||
|
let gridCol: { fieldName?: string; name?: string } | undefined;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const c = gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i);
|
||||||
|
if (!c) break;
|
||||||
|
if (c.fieldName === (col.fieldName || col.name) || c.name === (col.fieldName || col.name)) {
|
||||||
|
gridCol = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gridCol) {
|
||||||
|
gridCol = gridView.columnByName?.(col.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gridCol) return;
|
||||||
|
|
||||||
|
const fieldName = gridCol.fieldName || gridCol.name || col.fieldName || col.name;
|
||||||
|
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
|
||||||
|
|
||||||
|
// DOM에서 헤더 요소 찾기
|
||||||
|
const gridEl1 = document.querySelector('[data-grid-id]') as HTMLElement | null;
|
||||||
|
const gridEl2 = document.querySelector('.rg-grid') as HTMLElement | null;
|
||||||
|
const gridEl = gridEl1 || gridEl2;
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
// 헤더 셀 찾기 (fieldName 또는 name으로)
|
||||||
|
const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"]');
|
||||||
|
let targetCell: HTMLElement | null = null;
|
||||||
|
|
||||||
|
headerCells.forEach((cell) => {
|
||||||
|
const el = cell as HTMLElement;
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
const colName = col.name;
|
||||||
|
const colFieldName = col.fieldName;
|
||||||
|
if (text && (text.includes(colName) || (colFieldName && text.includes(colFieldName)))) {
|
||||||
|
targetCell = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetCell) return;
|
||||||
|
|
||||||
|
const headerCell = targetCell as HTMLElement;
|
||||||
|
|
||||||
|
// 기존 화살표 제거
|
||||||
|
const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null;
|
||||||
|
if (existingIndicator) {
|
||||||
|
existingIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화살표 추가
|
||||||
|
if (direction === "asc" || direction === "desc") {
|
||||||
|
const indicator = document.createElement("span");
|
||||||
|
indicator.className = "rg-sort-indicator";
|
||||||
|
indicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||||
|
indicator.style.marginLeft = "4px";
|
||||||
|
indicator.style.fontSize = "10px";
|
||||||
|
indicator.style.color = "#666";
|
||||||
|
indicator.style.display = "inline-block";
|
||||||
|
indicator.style.verticalAlign = "middle";
|
||||||
|
headerCell.appendChild(indicator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("updateHeaderSortIndicator error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
|
||||||
|
function createOnSortingHandler(
|
||||||
|
columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[],
|
||||||
|
setRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>,
|
||||||
|
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
|
||||||
|
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>
|
||||||
|
) {
|
||||||
|
const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))];
|
||||||
|
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
|
||||||
|
// 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
|
||||||
|
const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName);
|
||||||
|
const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0];
|
||||||
|
const groupField = mergeCellCol?.fieldName || null;
|
||||||
|
const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음
|
||||||
|
const hasMergeCells = mergeCellCols.length > 0;
|
||||||
|
|
||||||
|
return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) {
|
||||||
|
const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } };
|
||||||
|
const idx = fields?.[0];
|
||||||
|
if (idx === undefined) return true;
|
||||||
|
let sortField: string | null = null;
|
||||||
|
if (typeof idx === "string") sortField = idx;
|
||||||
|
else {
|
||||||
|
const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0);
|
||||||
|
sortField = (colObj?.fieldName ?? colObj?.name) ?? null;
|
||||||
|
}
|
||||||
|
if (!sortField) return true;
|
||||||
|
const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField));
|
||||||
|
if (!col || col.sortdisable) return true;
|
||||||
|
|
||||||
|
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
|
||||||
|
if (!hasMergeCells || !groupField) return true;
|
||||||
|
|
||||||
|
const ds = grid.getDataSource?.();
|
||||||
|
if (!ds) return true;
|
||||||
|
const cnt = ds.getRowCount();
|
||||||
|
const rows: Record<string, string | number>[] = [];
|
||||||
|
for (let i = 0; i < cnt; i++) {
|
||||||
|
const r: Record<string, string | number> = {};
|
||||||
|
for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
|
||||||
|
rows.push(r);
|
||||||
|
}
|
||||||
|
const prev = sortDirRef.current[sortField] ?? "none";
|
||||||
|
const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds);
|
||||||
|
|
||||||
|
if (prev === "desc") {
|
||||||
|
const orig = originalRowsRef.current[sortField];
|
||||||
|
if (orig && setRows) {
|
||||||
|
setRows(orig);
|
||||||
|
setRowData(orig);
|
||||||
|
}
|
||||||
|
sortDirRef.current[sortField] = "none";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev === "none") {
|
||||||
|
originalRowsRef.current[sortField] = rows.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
const sortAsc = prev !== "asc";
|
||||||
|
sortDirRef.current[sortField] = sortAsc ? "asc" : "desc";
|
||||||
|
const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType);
|
||||||
|
setRows?.(sorted);
|
||||||
|
setRowData(sorted);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
|
||||||
|
function sortRowsGroupPreserving(
|
||||||
|
rows: Record<string, string | number>[],
|
||||||
|
sortField: string,
|
||||||
|
sortAsc: boolean,
|
||||||
|
opt: { groupField: string; orderByField?: string },
|
||||||
|
valueType?: ValueType
|
||||||
|
): Record<string, string | number>[] {
|
||||||
|
const groups = new Map<string, Record<string, string | number>[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = String(r[opt.groupField] ?? "");
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k)!.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueType에 따라 비교 함수 선택
|
||||||
|
const compareValue = (val: unknown): number | string => {
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
return toNum(val);
|
||||||
|
}
|
||||||
|
// 텍스트나 기타 타입은 문자열로 비교
|
||||||
|
return String(val ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const rep = (arr: Record<string, string | number>[]): number | string => {
|
||||||
|
const values = arr.map((r) => compareValue(r[sortField]));
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
|
if (nums.length === 0) return sortAsc ? Infinity : -Infinity;
|
||||||
|
return sortAsc ? Math.min(...nums) : Math.max(...nums);
|
||||||
|
} else {
|
||||||
|
// 문자열 정렬
|
||||||
|
const strs = values.filter((v): v is string => typeof v === "string");
|
||||||
|
if (strs.length === 0) return sortAsc ? "\uffff" : "";
|
||||||
|
strs.sort();
|
||||||
|
return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = [...groups.entries()].sort((a, b) => {
|
||||||
|
const valA = rep(a[1]);
|
||||||
|
const valB = rep(b[1]);
|
||||||
|
if (typeof valA === "number" && typeof valB === "number") {
|
||||||
|
return (sortAsc ? 1 : -1) * (valA - valB);
|
||||||
|
}
|
||||||
|
// 문자열 비교
|
||||||
|
const strA = String(valA);
|
||||||
|
const strB = String(valB);
|
||||||
|
return (sortAsc ? 1 : -1) * strA.localeCompare(strB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries.flatMap(([, arr]) => arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
||||||
const { columns, columnLayout } = columnInfo;
|
const { columns, columnLayout } = columnInfo;
|
||||||
const gridRef = useRef<RealGridReact>(null);
|
const gridRef = useRef<RealGridReact>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sortDirRef = useRef<Record<string, "asc" | "desc" | "none">>({});
|
||||||
|
const originalRowsRef = useRef<Record<string, Record<string, string | number>[]>>({});
|
||||||
const { setMessage: alert } = useAlertStore((state) => state);
|
const { setMessage: alert } = useAlertStore((state) => state);
|
||||||
const activeTabId = useTabStore((state) => state.activeTabId);
|
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||||
const menuList = useTabStore((state) => state.menuList);
|
const menuList = useTabStore((state) => state.menuList);
|
||||||
@@ -134,6 +390,21 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
||||||
const dataStartRow = maxHeaderRowIndex + 1;
|
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>[] = [];
|
const dataRows: Record<string, string | number>[] = [];
|
||||||
for (let row = dataStartRow; row <= range.e.r; row++) {
|
for (let row = dataStartRow; row <= range.e.r; row++) {
|
||||||
@@ -144,6 +415,8 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
const col = range.s.c + colIndex;
|
const col = range.s.c + colIndex;
|
||||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
||||||
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
||||||
|
// 헤더 텍스트를 fieldName으로 변환
|
||||||
|
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
|
||||||
|
|
||||||
let cellValue: string | number = '';
|
let cellValue: string | number = '';
|
||||||
if (cell && cell.v !== undefined && cell.v !== null) {
|
if (cell && cell.v !== undefined && cell.v !== null) {
|
||||||
@@ -166,7 +439,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
hasData = true;
|
hasData = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
rowData[headerName] = cellValue;
|
rowData[fieldName] = cellValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터가 하나라도 있는 행만 추가
|
// 데이터가 하나라도 있는 행만 추가
|
||||||
@@ -208,55 +481,29 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
const gridView = gridRef.current.gridView;
|
const gridView = gridRef.current.gridView;
|
||||||
const dataProvider = gridRef.current.dataProvider;
|
const dataProvider = gridRef.current.dataProvider;
|
||||||
|
|
||||||
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
|
||||||
const fieldMap = new Map<string, string>();
|
const hasMergeCells = columns.some((col) => col.mergeCells === true);
|
||||||
|
|
||||||
columns.forEach(col => {
|
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
|
||||||
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
|
const dp = dataProvider as RealGridDataProviderComparer;
|
||||||
fieldMap.set(col.fieldName, col.valueType);
|
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
|
||||||
fieldsToSet.push({
|
const numberComparer = createNumberComparer(dp);
|
||||||
fieldName: col.fieldName,
|
const seen = new Set<string>();
|
||||||
dataType: col.valueType
|
columns.forEach((col) => {
|
||||||
});
|
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
|
||||||
}
|
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
|
||||||
});
|
seen.add(col.fieldName);
|
||||||
|
|
||||||
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
|
|
||||||
if (fieldsToSet.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
// 기존 필드 정보 가져오기
|
dp.setDataComparer!(col.fieldName, numberComparer);
|
||||||
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
|
} catch (e) {
|
||||||
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
|
console.warn("setDataComparer:", e);
|
||||||
|
}
|
||||||
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
|
|
||||||
const fieldsToUpdate = fieldsToSet.filter(field => {
|
|
||||||
const existingDataType = existingFieldMap.get(field.fieldName);
|
|
||||||
return existingDataType !== field.dataType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fieldsToUpdate.length > 0) {
|
|
||||||
// 기존 필드 정보를 유지하면서 dataType만 업데이트
|
|
||||||
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
|
|
||||||
const allFields = existingFields.map(f => ({
|
|
||||||
fieldName: f.fieldName,
|
|
||||||
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 새로 추가할 필드가 있으면 추가
|
|
||||||
fieldsToSet.forEach(field => {
|
|
||||||
if (!existingFieldMap.has(field.fieldName)) {
|
|
||||||
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// setFields를 호출하여 필드의 dataType 업데이트
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
|
||||||
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
|
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
|
||||||
.setFields(allFields);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to set field dataType:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
|
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
|
||||||
gridView.header.height = 60;
|
gridView.header.height = 60;
|
||||||
@@ -264,13 +511,32 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
// 헤더 체크박스 표시 여부 설정
|
// 헤더 체크박스 표시 여부 설정
|
||||||
gridView.setOptions({
|
gridView.setOptions({
|
||||||
checkBar: { visible: showHeaderCheckbox },
|
checkBar: { visible: showHeaderCheckbox },
|
||||||
|
rowIndicator: { visible: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
||||||
const hasFooter = columns.some(col => col.footer);
|
const hasFooter = columns.some(col => col.footer);
|
||||||
if (hasFooter) {
|
if (hasFooter) {
|
||||||
|
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
const hasMultiRowFooter = columns.some(col => {
|
||||||
|
const footer = col.footer;
|
||||||
|
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
|
||||||
|
});
|
||||||
|
if (hasArrayFooter || hasMultiRowFooter) {
|
||||||
|
// footer 배열의 길이를 확인하여 행 수 결정
|
||||||
|
const arrayFooterLengths = columns
|
||||||
|
.filter(col => col.footer && Array.isArray(col.footer))
|
||||||
|
.map(col => (col.footer as unknown[]).length);
|
||||||
|
const maxFooterLength = arrayFooterLengths.length > 0
|
||||||
|
? Math.max(...arrayFooterLengths)
|
||||||
|
: 2; // 기본값 2
|
||||||
|
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
|
||||||
|
gridView.setFooters?.(footerHeights);
|
||||||
|
} else {
|
||||||
gridView.footer.height = 40;
|
gridView.footer.height = 40;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 고정 컬럼 설정
|
// 고정 컬럼 설정
|
||||||
if (fixedColumns !== undefined && fixedColumns > 0) {
|
if (fixedColumns !== undefined && fixedColumns > 0) {
|
||||||
@@ -283,18 +549,117 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
summaryMode: "aggregate"
|
summaryMode: "aggregate"
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer의 valueCallback 설정
|
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||||
|
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||||
|
return layout.some(item => {
|
||||||
|
if (typeof item === 'string') return false;
|
||||||
|
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||||
|
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||||
|
return checkFooterUserSpans(item.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFooterUserSpans(columnLayout)) {
|
||||||
|
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gridRef.current?.gridView && columnLayout) {
|
||||||
|
gridRef.current.gridView.setColumnLayout?.(columnLayout);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||||
|
// setFooters 호출 후 footer 배열 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentGridView = gridRef.current?.gridView;
|
||||||
|
if (!currentGridView || !currentGridView.columnByName) {
|
||||||
|
console.warn('GridView not available for footer setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
if (hasArrayFooter) {
|
||||||
|
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||||
|
const columnsWithFooter: 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) {
|
||||||
|
if (Array.isArray(col.footer)) {
|
||||||
|
colInfo.footer = col.footer;
|
||||||
|
} else {
|
||||||
|
colInfo.footer = {
|
||||||
|
text: col.footer.text,
|
||||||
|
expression: col.footer.expression,
|
||||||
|
numberFormat: col.footer.numberFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||||
|
|
||||||
|
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||||
|
setTimeout(() => {
|
||||||
columns.forEach(col => {
|
columns.forEach(col => {
|
||||||
if (col.footer && col.footer.valueCallback) {
|
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||||
const column = gridView.columnByName(col.name);
|
|
||||||
if (column && column.footer) {
|
try {
|
||||||
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
// footer 배열의 각 요소에 valueCallback 설정
|
||||||
|
col.footer.forEach((footerItem, index) => {
|
||||||
|
if (footerItem.valueCallback && column.footer) {
|
||||||
|
// footer가 배열인 경우
|
||||||
|
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||||
|
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set columns with footer array:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column || !column.footer) return;
|
||||||
|
|
||||||
|
if (col.footer.valueCallback) {
|
||||||
|
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
|
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
|
||||||
<input
|
<input
|
||||||
@@ -324,6 +689,13 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
autoGenerateField={true}
|
autoGenerateField={true}
|
||||||
layout={columnLayout}
|
layout={columnLayout}
|
||||||
rows={rowData}
|
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) => (
|
{columns.map((column) => (
|
||||||
<RGDataColumn
|
<RGDataColumn
|
||||||
@@ -334,7 +706,8 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
|
|||||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
||||||
header={column.header}
|
header={column.header}
|
||||||
editable={column.editable}
|
editable={column.editable}
|
||||||
footer={column.footer ? {
|
{...(column.visible === false && { visible: false })}
|
||||||
|
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||||
text: column.footer.text,
|
text: column.footer.text,
|
||||||
expression: column.footer.expression,
|
expression: column.footer.expression,
|
||||||
numberFormat: column.footer.numberFormat,
|
numberFormat: column.footer.numberFormat,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ValueType } from "realgrid"
|
|||||||
|
|
||||||
import RealGrid, { GridFitStyle } from "realgrid"
|
import RealGrid, { GridFitStyle } from "realgrid"
|
||||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
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 JSZip from "jszip"
|
||||||
import * as XLSX from "xlsx"
|
import * as XLSX from "xlsx"
|
||||||
import { useAlertStore } from "../store/alert-store";
|
import { useAlertStore } from "../store/alert-store";
|
||||||
@@ -24,6 +25,16 @@ declare module "realgrid" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RealGridDataProviderFill {
|
||||||
|
fillJsonData?(data: Record<string, string | number>[], options?: { fillMode?: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||||
|
interface RealGridDataProviderComparer {
|
||||||
|
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||||
|
getValue?(row: number, field: string | number): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// Window 인터페이스 확장
|
// Window 인터페이스 확장
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -38,10 +49,270 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||||
|
|
||||||
|
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
|
||||||
|
function toNum(v: unknown): number {
|
||||||
|
if (v == null) return NaN;
|
||||||
|
if (typeof v === "number") return isNaN(v) ? NaN : v;
|
||||||
|
return parseFloat(String(v).replace(/,/g, "")) || NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
|
||||||
|
function updateHeaderSortIndicator(
|
||||||
|
gridView: {
|
||||||
|
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||||
|
getElement?: () => HTMLElement | null;
|
||||||
|
container?: HTMLElement;
|
||||||
|
},
|
||||||
|
columns: { fieldName?: string; name: string }[],
|
||||||
|
sortDir: Record<string, "asc" | "desc" | "none">,
|
||||||
|
gridElement?: HTMLElement | null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 그리드 DOM 요소 찾기
|
||||||
|
let gridEl: HTMLElement | null = null;
|
||||||
|
if (gridElement) {
|
||||||
|
gridEl = gridElement;
|
||||||
|
} else if (gridView.getElement) {
|
||||||
|
gridEl = gridView.getElement();
|
||||||
|
} else if (gridView.container) {
|
||||||
|
gridEl = gridView.container;
|
||||||
|
} else {
|
||||||
|
// 폴백: 모든 그리드 중에서 찾기
|
||||||
|
const allGrids = document.querySelectorAll('.rg-grid, [data-grid-id]');
|
||||||
|
if (allGrids.length > 0) {
|
||||||
|
// 활성 탭의 그리드를 찾기 위해 TabsContent 내부의 그리드 찾기
|
||||||
|
const activeTabContent = document.querySelector('[data-state="active"]')?.closest('[role="tabpanel"]');
|
||||||
|
if (activeTabContent) {
|
||||||
|
gridEl = activeTabContent.querySelector('.rg-grid, [data-grid-id]') as HTMLElement;
|
||||||
|
}
|
||||||
|
if (!gridEl && allGrids.length === 1) {
|
||||||
|
gridEl = allGrids[0] as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
columns.forEach((col) => {
|
||||||
|
if (!col.fieldName && !col.name) return;
|
||||||
|
|
||||||
|
const fieldName = col.fieldName || col.name;
|
||||||
|
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
|
||||||
|
if (direction === "none") return;
|
||||||
|
|
||||||
|
// 헤더 셀 찾기 (fieldName 또는 name으로)
|
||||||
|
const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"], .rg-header');
|
||||||
|
let targetCell: HTMLElement | null = null;
|
||||||
|
|
||||||
|
headerCells.forEach((cell) => {
|
||||||
|
const el = cell as HTMLElement;
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
const colName = col.name;
|
||||||
|
const colFieldName = col.fieldName;
|
||||||
|
// 헤더 텍스트와 컬럼 이름이 일치하는지 확인
|
||||||
|
if (text && (
|
||||||
|
text === colName ||
|
||||||
|
text === colFieldName ||
|
||||||
|
text.includes(colName) ||
|
||||||
|
(colFieldName && text.includes(colFieldName))
|
||||||
|
)) {
|
||||||
|
targetCell = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetCell) return;
|
||||||
|
|
||||||
|
const headerCell = targetCell as HTMLElement;
|
||||||
|
|
||||||
|
// 기존 화살표 제거
|
||||||
|
const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null;
|
||||||
|
if (existingIndicator) {
|
||||||
|
existingIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화살표 추가
|
||||||
|
const indicator = document.createElement("span");
|
||||||
|
indicator.className = "rg-sort-indicator";
|
||||||
|
indicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||||
|
indicator.style.marginLeft = "4px";
|
||||||
|
indicator.style.fontSize = "10px";
|
||||||
|
indicator.style.color = "#666";
|
||||||
|
indicator.style.display = "inline-block";
|
||||||
|
indicator.style.verticalAlign = "middle";
|
||||||
|
headerCell.appendChild(indicator);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("updateHeaderSortIndicator error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
|
||||||
|
function createOnSortingHandler(
|
||||||
|
columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[],
|
||||||
|
setRowData: (updater: React.SetStateAction<Record<string, string | number>[]>) => void,
|
||||||
|
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
|
||||||
|
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>,
|
||||||
|
findGridElement?: () => HTMLElement | null
|
||||||
|
) {
|
||||||
|
const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))];
|
||||||
|
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
|
||||||
|
// 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
|
||||||
|
const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName);
|
||||||
|
const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0];
|
||||||
|
const groupField = mergeCellCol?.fieldName || null;
|
||||||
|
const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음
|
||||||
|
const hasMergeCells = mergeCellCols.length > 0;
|
||||||
|
|
||||||
|
return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) {
|
||||||
|
const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } };
|
||||||
|
const idx = fields?.[0];
|
||||||
|
if (idx === undefined) return true;
|
||||||
|
let sortField: string | null = null;
|
||||||
|
if (typeof idx === "string") sortField = idx;
|
||||||
|
else {
|
||||||
|
const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0);
|
||||||
|
sortField = (colObj?.fieldName ?? colObj?.name) ?? null;
|
||||||
|
}
|
||||||
|
if (!sortField) return true;
|
||||||
|
const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField));
|
||||||
|
if (!col || col.sortdisable) return true;
|
||||||
|
|
||||||
|
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
|
||||||
|
if (!hasMergeCells || !groupField) return true;
|
||||||
|
|
||||||
|
const ds = grid.getDataSource?.();
|
||||||
|
if (!ds) return true;
|
||||||
|
const cnt = ds.getRowCount();
|
||||||
|
const rows: Record<string, string | number>[] = [];
|
||||||
|
for (let i = 0; i < cnt; i++) {
|
||||||
|
const r: Record<string, string | number> = {};
|
||||||
|
for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
|
||||||
|
rows.push(r);
|
||||||
|
}
|
||||||
|
const prev = sortDirRef.current[sortField] ?? "none";
|
||||||
|
const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds);
|
||||||
|
|
||||||
|
if (prev === "desc") {
|
||||||
|
const orig = originalRowsRef.current[sortField];
|
||||||
|
if (orig && setRows) {
|
||||||
|
setRows(orig);
|
||||||
|
setRowData(orig);
|
||||||
|
}
|
||||||
|
sortDirRef.current[sortField] = "none";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev === "none") {
|
||||||
|
originalRowsRef.current[sortField] = rows.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
const sortAsc = prev !== "asc";
|
||||||
|
sortDirRef.current[sortField] = sortAsc ? "asc" : "desc";
|
||||||
|
const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType);
|
||||||
|
setRows?.(sorted);
|
||||||
|
setRowData(sorted);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
|
||||||
|
function sortRowsGroupPreserving(
|
||||||
|
rows: Record<string, string | number>[],
|
||||||
|
sortField: string,
|
||||||
|
sortAsc: boolean,
|
||||||
|
opt: { groupField: string; orderByField?: string },
|
||||||
|
valueType?: ValueType
|
||||||
|
): Record<string, string | number>[] {
|
||||||
|
const groups = new Map<string, Record<string, string | number>[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = String(r[opt.groupField] ?? "");
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k)!.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueType에 따라 비교 함수 선택
|
||||||
|
const compareValue = (val: unknown): number | string => {
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
return toNum(val);
|
||||||
|
}
|
||||||
|
// 텍스트나 기타 타입은 문자열로 비교
|
||||||
|
return String(val ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const rep = (arr: Record<string, string | number>[]): number | string => {
|
||||||
|
const values = arr.map((r) => compareValue(r[sortField]));
|
||||||
|
if (valueType === ValueType.NUMBER) {
|
||||||
|
const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
|
if (nums.length === 0) return sortAsc ? Infinity : -Infinity;
|
||||||
|
return sortAsc ? Math.min(...nums) : Math.max(...nums);
|
||||||
|
} else {
|
||||||
|
// 문자열 정렬
|
||||||
|
const strs = values.filter((v): v is string => typeof v === "string");
|
||||||
|
if (strs.length === 0) return sortAsc ? "\uffff" : "";
|
||||||
|
strs.sort();
|
||||||
|
return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = [...groups.entries()].sort((a, b) => {
|
||||||
|
const valA = rep(a[1]);
|
||||||
|
const valB = rep(b[1]);
|
||||||
|
if (typeof valA === "number" && typeof valB === "number") {
|
||||||
|
return (sortAsc ? 1 : -1) * (valA - valB);
|
||||||
|
}
|
||||||
|
// 문자열 비교
|
||||||
|
const strA = String(valA);
|
||||||
|
const strB = String(valB);
|
||||||
|
return (sortAsc ? 1 : -1) * strA.localeCompare(strB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries.flatMap(([, arr]) => arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const get = dp.getValue?.bind(dp);
|
||||||
|
if (!get) return 0;
|
||||||
|
const val1 = get(row1, field);
|
||||||
|
const val2 = get(row2, field);
|
||||||
|
if (val1 == null && val2 == null) return 0;
|
||||||
|
if (val1 == null) return 1;
|
||||||
|
if (val2 == null) return -1;
|
||||||
|
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||||
|
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||||
|
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||||
|
if (isNaN(num1)) return 1;
|
||||||
|
if (isNaN(num2)) return -1;
|
||||||
|
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
|
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
|
||||||
// 각 탭별로 그리드 ref와 데이터를 관리
|
// 각 탭별로 그리드 ref와 데이터를 관리
|
||||||
const gridRefs = useRef<Map<string, RealGridReact>>(new Map());
|
const gridRefs = useRef<Map<string, RealGridReact>>(new Map());
|
||||||
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
||||||
|
// 각 탭별로 정렬 상태 관리
|
||||||
|
const sortDirRefs = useRef<Map<string, React.MutableRefObject<Record<string, "asc" | "desc" | "none">>>>(new Map());
|
||||||
|
const originalRowsRefs = useRef<Map<string, React.MutableRefObject<Record<string, Record<string, string | number>[]>>>>(new Map());
|
||||||
const { setMessage: alert } = useAlertStore((state) => state);
|
const { setMessage: alert } = useAlertStore((state) => state);
|
||||||
const activeTabId = useTabStore((state) => state.activeTabId);
|
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||||
const menuList = useTabStore((state) => state.menuList);
|
const menuList = useTabStore((state) => state.menuList);
|
||||||
@@ -152,6 +423,22 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
||||||
const dataStartRow = maxHeaderRowIndex + 1;
|
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>[] = [];
|
const dataRows: Record<string, string | number>[] = [];
|
||||||
for (let row = dataStartRow; row <= range.e.r; row++) {
|
for (let row = dataStartRow; row <= range.e.r; row++) {
|
||||||
@@ -162,6 +449,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
const col = range.s.c + colIndex;
|
const col = range.s.c + colIndex;
|
||||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
||||||
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
||||||
|
// 헤더 텍스트를 fieldName으로 변환
|
||||||
|
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
|
||||||
|
|
||||||
let cellValue: string | number = '';
|
let cellValue: string | number = '';
|
||||||
if (cell && cell.v !== undefined && cell.v !== null) {
|
if (cell && cell.v !== undefined && cell.v !== null) {
|
||||||
@@ -184,7 +473,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
hasData = true;
|
hasData = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
rowData[headerName] = cellValue;
|
rowData[fieldName] = cellValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터가 하나라도 있는 행만 추가
|
// 데이터가 하나라도 있는 행만 추가
|
||||||
@@ -244,6 +533,30 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
}
|
}
|
||||||
}, [rows, columnInfo]);
|
}, [rows, columnInfo]);
|
||||||
|
|
||||||
|
// rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등)
|
||||||
|
useEffect(() => {
|
||||||
|
columnInfo.forEach((info) => {
|
||||||
|
const tabKey = info.tabKey;
|
||||||
|
const gridRef = gridRefs.current.get(tabKey);
|
||||||
|
const currentData = rowDataMap.get(tabKey);
|
||||||
|
|
||||||
|
if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const dp = gridRef.dataProvider as RealGridDataProviderFill | null;
|
||||||
|
if (dp?.fillJsonData) {
|
||||||
|
dp.fillJsonData(currentData, { fillMode: "set" });
|
||||||
|
}
|
||||||
|
gridRef.gridView.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그리드 데이터 설정 오류:", error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [rowDataMap, columnInfo]);
|
||||||
|
|
||||||
|
|
||||||
// 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때)
|
// 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gridRef = gridRefs.current.get(activeTab);
|
const gridRef = gridRefs.current.get(activeTab);
|
||||||
@@ -378,7 +691,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[60vh]">
|
<div className="h-[60vh]" data-tab-key={tabKey}>
|
||||||
<RealGridReact
|
<RealGridReact
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -392,6 +705,13 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
|
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
|
||||||
if (!currentInfo || !currentInfo.columns) return;
|
if (!currentInfo || !currentInfo.columns) return;
|
||||||
|
|
||||||
|
const columns = currentInfo.columns;
|
||||||
|
|
||||||
|
const dp = el.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined;
|
||||||
|
if (dp?.setFields) {
|
||||||
|
dp.setFields(buildFieldsFromColumns(columns));
|
||||||
|
}
|
||||||
|
|
||||||
gridView.setDisplayOptions({
|
gridView.setDisplayOptions({
|
||||||
fitStyle: GridFitStyle.EVEN
|
fitStyle: GridFitStyle.EVEN
|
||||||
});
|
});
|
||||||
@@ -404,24 +724,141 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
});
|
});
|
||||||
|
|
||||||
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
// footer가 있는 컬럼이 있으면 footer 높이 설정
|
||||||
const hasFooter = currentInfo.columns.some(col => col.footer);
|
const hasFooter = columns.some(col => col.footer);
|
||||||
if (hasFooter) {
|
if (hasFooter) {
|
||||||
|
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
const hasMultiRowFooter = columns.some(col => {
|
||||||
|
const footer = col.footer;
|
||||||
|
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
|
||||||
|
});
|
||||||
|
if (hasArrayFooter || hasMultiRowFooter) {
|
||||||
|
// footer 배열의 길이를 확인하여 행 수 결정
|
||||||
|
const arrayFooterLengths = columns
|
||||||
|
.filter(col => col.footer && Array.isArray(col.footer))
|
||||||
|
.map(col => (col.footer as unknown[]).length);
|
||||||
|
const maxFooterLength = arrayFooterLengths.length > 0
|
||||||
|
? Math.max(...arrayFooterLengths)
|
||||||
|
: 2; // 기본값 2
|
||||||
|
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
|
||||||
|
gridView.setFooters?.(footerHeights);
|
||||||
|
} else {
|
||||||
gridView.footer.height = 40;
|
gridView.footer.height = 40;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gridView.setOptions({
|
gridView.setOptions({
|
||||||
summaryMode: "aggregate"
|
summaryMode: "aggregate"
|
||||||
});
|
});
|
||||||
|
|
||||||
// footer의 valueCallback 설정
|
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||||
currentInfo.columns.forEach(col => {
|
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||||
if (col.footer && col.footer.valueCallback) {
|
return layout.some(item => {
|
||||||
const column = gridView.columnByName(col.name);
|
if (typeof item === 'string') return false;
|
||||||
if (column && column.footer) {
|
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||||
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
|
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||||
|
return checkFooterUserSpans(item.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFooterUserSpans(columnLayout)) {
|
||||||
|
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gridView && columnLayout) {
|
||||||
|
gridView.setColumnLayout?.(columnLayout);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||||
|
// setFooters 호출 후 footer 배열 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentGridView = gridView;
|
||||||
|
if (!currentGridView || !currentGridView.columnByName) {
|
||||||
|
console.warn('GridView not available for footer setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||||
|
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||||
|
if (hasArrayFooter) {
|
||||||
|
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||||
|
const columnsWithFooter: 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) {
|
||||||
|
if (Array.isArray(col.footer)) {
|
||||||
|
colInfo.footer = col.footer;
|
||||||
|
} else {
|
||||||
|
colInfo.footer = {
|
||||||
|
text: col.footer.text,
|
||||||
|
expression: col.footer.expression,
|
||||||
|
numberFormat: col.footer.numberFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||||
|
|
||||||
|
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
// footer 배열의 각 요소에 valueCallback 설정
|
||||||
|
col.footer.forEach((footerItem, index) => {
|
||||||
|
if (footerItem.valueCallback && column.footer) {
|
||||||
|
// footer가 배열인 경우
|
||||||
|
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||||
|
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set columns with footer array:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||||
|
columns.forEach(col => {
|
||||||
|
if (!col.footer || Array.isArray(col.footer)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const column = currentGridView.columnByName(col.name);
|
||||||
|
if (!column || !column.footer) return;
|
||||||
|
|
||||||
|
const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown };
|
||||||
|
if (footerObj.valueCallback) {
|
||||||
|
(column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
// 고정 컬럼 설정
|
// 고정 컬럼 설정
|
||||||
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
|
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
|
||||||
@@ -430,60 +867,60 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// valueType이 설정된 컬럼에 대해 필드의 dataType을 명시적으로 설정
|
// 각 탭별 ref 초기화
|
||||||
// autoGenerateField={true}일 때 valueType prop만으로는 필드의 dataType이 설정되지 않을 수 있음
|
if (!sortDirRefs.current.has(tabKey)) {
|
||||||
|
sortDirRefs.current.set(tabKey, { current: {} });
|
||||||
|
}
|
||||||
|
if (!originalRowsRefs.current.has(tabKey)) {
|
||||||
|
originalRowsRefs.current.set(tabKey, { current: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortDirRef = sortDirRefs.current.get(tabKey)!;
|
||||||
|
const originalRowsRef = originalRowsRefs.current.get(tabKey)!;
|
||||||
|
|
||||||
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
|
||||||
|
const hasMergeCells = columns.some((col) => col.mergeCells === true);
|
||||||
|
|
||||||
|
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
|
||||||
if (el.dataProvider) {
|
if (el.dataProvider) {
|
||||||
const dataProvider = el.dataProvider;
|
const dp = el.dataProvider as RealGridDataProviderComparer;
|
||||||
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
|
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
|
||||||
const fieldMap = new Map<string, string>();
|
const numberComparer = createNumberComparer(dp);
|
||||||
|
const seen = new Set<string>();
|
||||||
currentInfo.columns.forEach(col => {
|
columns.forEach((col) => {
|
||||||
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
|
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
|
||||||
fieldMap.set(col.fieldName, col.valueType);
|
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
|
||||||
fieldsToSet.push({
|
seen.add(col.fieldName);
|
||||||
fieldName: col.fieldName,
|
|
||||||
dataType: col.valueType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
|
|
||||||
if (fieldsToSet.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
// 기존 필드 정보 가져오기
|
dp.setDataComparer!(col.fieldName, numberComparer);
|
||||||
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
|
} catch (e) {
|
||||||
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
|
console.warn("setDataComparer:", e);
|
||||||
|
}
|
||||||
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
|
|
||||||
const fieldsToUpdate = fieldsToSet.filter(field => {
|
|
||||||
const existingDataType = existingFieldMap.get(field.fieldName);
|
|
||||||
return existingDataType !== field.dataType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fieldsToUpdate.length > 0) {
|
|
||||||
// 기존 필드 정보를 유지하면서 dataType만 업데이트
|
|
||||||
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
|
|
||||||
const allFields = existingFields.map(f => ({
|
|
||||||
fieldName: f.fieldName,
|
|
||||||
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 새로 추가할 필드가 있으면 추가
|
|
||||||
fieldsToSet.forEach(field => {
|
|
||||||
if (!existingFieldMap.has(field.fieldName)) {
|
|
||||||
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// setFields를 호출하여 필드의 dataType 업데이트
|
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
|
||||||
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
|
const setRowData = (updater: React.SetStateAction<Record<string, string | number>[]>) => {
|
||||||
.setFields(allFields);
|
setRowDataMap(prev => {
|
||||||
}
|
const newMap = new Map(prev);
|
||||||
} catch (error) {
|
const currentData = newMap.get(tabKey) || [];
|
||||||
console.warn('Failed to set field dataType:', error);
|
const newData = typeof updater === 'function' ? updater(currentData) : updater;
|
||||||
}
|
newMap.set(tabKey, newData);
|
||||||
}
|
return newMap;
|
||||||
}
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그리드 DOM 요소 찾기 (나중에 화살표 업데이트에 사용)
|
||||||
|
// 탭별로 그리드 요소를 찾기 위해 data-tab-key 사용
|
||||||
|
const findGridElement = () => {
|
||||||
|
const tabContent = document.querySelector(`[data-tab-key="${tabKey}"]`);
|
||||||
|
return tabContent?.querySelector('.rg-grid, [data-grid-id]') as HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement);
|
||||||
|
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = onSortingHandler;
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -503,10 +940,11 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
|
|||||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
|
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
|
||||||
header={column.header}
|
header={column.header}
|
||||||
editable={column.editable}
|
editable={column.editable}
|
||||||
footer={column.footer ? {
|
{...(column.visible === false && { visible: false })}
|
||||||
text: column.footer.text,
|
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||||
expression: column.footer.expression,
|
text: (column.footer as { text?: string }).text,
|
||||||
numberFormat: column.footer.numberFormat,
|
expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression,
|
||||||
|
numberFormat: (column.footer as { numberFormat?: string }).numberFormat,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
||||||
sortable={!column.sortdisable}
|
sortable={!column.sortdisable}
|
||||||
|
|||||||
@@ -2,6 +2,34 @@ import React from "react";
|
|||||||
import { ValueType } from "realgrid";
|
import { ValueType } from "realgrid";
|
||||||
|
|
||||||
// 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 = {
|
export type GridView = {
|
||||||
getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined;
|
getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined;
|
||||||
getDataSource(): {
|
getDataSource(): {
|
||||||
@@ -25,13 +53,40 @@ export type ColumnFooter = {
|
|||||||
numberFormat?: string;
|
numberFormat?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */
|
||||||
|
export type SortPreserveGroupOrder = {
|
||||||
|
groupField: string;
|
||||||
|
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 = {
|
export type ColumnConfig = {
|
||||||
name: string;
|
name: string;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
header: { text: string };
|
header: { text: string };
|
||||||
|
/** 계산 필드: fields 합계/평균 등. valueCallback으로 dataProvider.setFields 시 전달 */
|
||||||
|
computed?: ComputedFieldConfig;
|
||||||
editable?: boolean; // 셀 편집 가능 여부
|
editable?: boolean; // 셀 편집 가능 여부
|
||||||
|
/** 그리드에서 컬럼 숨김 (수식용 필드 등) */
|
||||||
|
visible?: boolean;
|
||||||
mergeCells?: boolean;
|
mergeCells?: boolean;
|
||||||
sortdisable?: boolean;
|
sortdisable?: boolean;
|
||||||
|
sortPreserveGroupOrder?: SortPreserveGroupOrder;
|
||||||
valueType?: ValueType;
|
valueType?: ValueType;
|
||||||
dataFormat?: string;
|
dataFormat?: string;
|
||||||
footer?: {
|
footer?: {
|
||||||
@@ -39,7 +94,19 @@ export type ColumnConfig = {
|
|||||||
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||||
numberFormat?: string;
|
numberFormat?: string;
|
||||||
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||||
},
|
multiRow?: boolean; // 여러 행 footer 사용 여부
|
||||||
|
} | Array<{
|
||||||
|
text?: string;
|
||||||
|
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||||
|
numberFormat?: string;
|
||||||
|
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||||
|
}>, // RealGrid에서 footer를 배열로 설정할 수 있음
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FooterUserSpan = {
|
||||||
|
rowspan?: number; // 병합할 행 수
|
||||||
|
colspan?: number; // 병합할 컬럼 수
|
||||||
|
text?: string; // 표시할 텍스트
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ColumnLayoutItem = string | {
|
export type ColumnLayoutItem = string | {
|
||||||
@@ -47,6 +114,10 @@ export type ColumnLayoutItem = string | {
|
|||||||
hideChildHeaders?: boolean;
|
hideChildHeaders?: boolean;
|
||||||
header: { text: string };
|
header: { text: string };
|
||||||
items: (string | ColumnLayoutItem)[];
|
items: (string | ColumnLayoutItem)[];
|
||||||
|
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
|
||||||
|
} | {
|
||||||
|
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
|
||||||
|
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ColumnInfo = {
|
export type ColumnInfo = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user