st: refactor:

- 컴포넌트 전체적으로 정리
- 그리드 컴포넌트 sort 기능 및 foooter 기능 수정
- menu active 기능 수정
This commit is contained in:
2026-01-29 10:23:05 +09:00
parent af636453e0
commit 68c5548252
60 changed files with 2430 additions and 466 deletions
+51 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useQueryClient, useQuery } from "@tanstack/react-query";
@@ -30,10 +30,14 @@ export default function WithBaseLayout({
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
const setMenuList = useTabStore((state) => state.setMenuList);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const tabs = useTabStore((state) => state.tabs);
const addTabForGrid = gridTabStore((state) => state.addTab);
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
@@ -64,6 +68,50 @@ export default function WithBaseLayout({
}
}, [pathname, setActiveTab, setActiveTabForGrid]);
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
const currentPath = useMemo(() => {
if (tabs.length === 0) return "";
if (activeTabId && menuList) {
// menuList에서 활성 탭의 메뉴 찾기
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
for (const item of items) {
if (item.menuEngNm === activeTabId) {
return item;
}
if (item.children) {
const found = findMenuByEngNm(item.children);
if (found) return found;
}
}
return null;
};
const activeMenu = findMenuByEngNm(menuList);
if (activeMenu?.urlPath) {
return activeMenu.urlPath;
}
}
// 탭에서 직접 찾기
if (activeTabId) {
const currentTabs = getTabStoreState().tabs;
const activeTab = currentTabs.find((t) => t.id === activeTabId);
if (activeTab?.menuItem?.urlPath) {
return activeTab.menuItem.urlPath;
}
}
// currentMenuInfo에서 가져오기
if (currentMenuInfo?.urlPath) {
return currentMenuInfo.urlPath;
}
// 기본값으로 pathname 사용
return pathname ?? "";
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
const handleMenuClick = useCallback((item: MenuItem) => {
setCurrentMenuInfo(item);
const addTabResult: boolean = addTab(item);
@@ -77,12 +125,12 @@ export default function WithBaseLayout({
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
}, [setCurrentMenuInfo, addTab, addTabForGrid]);
}, [setCurrentMenuInfo, addTab, addTabForGrid, alert]);
return (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<StatisticsPageLayout>{children}</StatisticsPageLayout>
@@ -13,7 +13,7 @@ const NotApplicableCheckButton = ({ isCheck, setIsCheck }: NotApplicableCheckBut
};
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>
<Checkbox
id="notApplicable"
+163 -56
View File
@@ -18,6 +18,31 @@ declare module "realgrid" {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
interface RealGridDataProviderComparer {
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
getValue?(row: number, field: string | number): unknown;
}
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
return (field, row1, row2) => {
const get = dp.getValue?.bind(dp);
if (!get) return 0;
const val1 = get(row1, field);
const val2 = get(row2, field);
if (val1 == null && val2 == null) return 0;
if (val1 == null) return 1;
if (val2 == null) return -1;
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
if (isNaN(num1) && isNaN(num2)) return 0;
if (isNaN(num1)) return 1;
if (isNaN(num2)) return -1;
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
};
}
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -28,54 +53,21 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
const gridView = gridRef.current.gridView;
const dataProvider = gridRef.current.dataProvider;
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
const fieldMap = new Map<string, string>();
columns.forEach(col => {
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
fieldMap.set(col.fieldName, col.valueType);
fieldsToSet.push({
fieldName: col.fieldName,
dataType: col.valueType
});
}
});
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
if (fieldsToSet.length > 0) {
try {
// 기존 필드 정보 가져오기
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
const fieldsToUpdate = fieldsToSet.filter(field => {
const existingDataType = existingFieldMap.get(field.fieldName);
return existingDataType !== field.dataType;
});
if (fieldsToUpdate.length > 0) {
// 기존 필드 정보를 유지하면서 dataType만 업데이트
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
const allFields = existingFields.map(f => ({
fieldName: f.fieldName,
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
}));
// 새로 추가할 필드가 있으면 추가
fieldsToSet.forEach(field => {
if (!existingFieldMap.has(field.fieldName)) {
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
}
});
// setFields를 호출하여 필드의 dataType 업데이트
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
.setFields(allFields);
// setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음)
const dp = dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
const numberComparer = createNumberComparer(dp);
const seen = new Set<string>();
columns.forEach((col) => {
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
seen.add(col.fieldName);
try {
dp.setDataComparer!(col.fieldName, numberComparer);
} catch (e) {
console.warn("setDataComparer:", e);
}
}
} catch (error) {
console.warn('Failed to set field dataType:', error);
}
});
}
gridView.setDisplayOptions({
@@ -92,7 +84,25 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
// footer가 있는 컬럼이 있으면 footer 높이 설정
const hasFooter = columns.some(col => col.footer);
if (hasFooter) {
gridView.footer.height = 40;
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
const hasMultiRowFooter = columns.some(col => {
const footer = col.footer;
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
});
if (hasArrayFooter || hasMultiRowFooter) {
// footer 배열의 길이를 확인하여 행 수 결정
const arrayFooterLengths = columns
.filter(col => col.footer && Array.isArray(col.footer))
.map(col => (col.footer as unknown[]).length);
const maxFooterLength = arrayFooterLengths.length > 0
? Math.max(...arrayFooterLengths)
: 2; // 기본값 2
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
gridView.setFooters?.(footerHeights);
} else {
gridView.footer.height = 40;
}
}
// 고정 컬럼 설정
@@ -106,15 +116,112 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
summaryMode: "aggregate"
});
// footer의 valueCallback 설정
columns.forEach(col => {
if (col.footer && col.footer.valueCallback) {
const column = gridView.columnByName(col.name);
if (column && column.footer) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
const checkFooterUserSpans = (layout: unknown[]): boolean => {
return layout.some(item => {
if (typeof item === 'string') return false;
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
return checkFooterUserSpans(item.items);
}
return false;
});
};
if (checkFooterUserSpans(columnLayout)) {
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridRef.current?.gridView && columnLayout) {
gridRef.current.gridView.setColumnLayout?.(columnLayout);
}
}, 100);
}
// footer 설정 (배열 또는 단일 객체 모두 처리)
// setFooters 호출 후 footer 배열 설정
setTimeout(() => {
const currentGridView = gridRef.current?.gridView;
if (!currentGridView || !currentGridView.columnByName) {
console.warn('GridView not available for footer setup');
return;
}
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
if (hasArrayFooter) {
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
const columnsWithFooter = columns.map(col => {
const colInfo: any = {
name: col.name,
fieldName: col.fieldName,
header: col.header,
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
};
if (col.footer) {
if (Array.isArray(col.footer)) {
colInfo.footer = col.footer;
} else {
colInfo.footer = {
text: col.footer.text,
expression: col.footer.expression,
numberFormat: col.footer.numberFormat,
};
}
}
return colInfo;
});
try {
(currentGridView as any).setColumns?.(columnsWithFooter);
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
setTimeout(() => {
columns.forEach(col => {
if (!col.footer || !Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column) return;
// footer 배열의 각 요소에 valueCallback 설정
col.footer.forEach((footerItem, index) => {
if (footerItem.valueCallback && column.footer) {
// footer가 배열인 경우
if (Array.isArray(column.footer) && column.footer[index]) {
(column.footer[index] as any).valueCallback = footerItem.valueCallback;
}
}
});
} catch (e) {
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
}
});
}, 50);
} catch (e) {
console.warn('Failed to set columns with footer array:', e);
}
}
});
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
if (col.footer.valueCallback) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
}
} catch (e) {
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
}, 200);
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
@@ -136,7 +243,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
header={column.header}
editable={column.editable}
footer={column.footer ? {
footer={column.footer && !Array.isArray(column.footer) ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
+361 -61
View File
@@ -19,8 +19,6 @@ declare module "realgrid" {
summaryMode?: 'aggregate' | 'statistical' | 'none';
checkBar?: { visible?: boolean };
}): void;
setFooters?(footers: Array<{ height?: number }>): void;
setColumnLayout?(layout: unknown[]): void; // footerUserSpans를 포함한 레이아웃 설정
}
}
@@ -38,10 +36,245 @@ if (typeof window !== "undefined") {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
interface RealGridDataProviderComparer {
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
getValue?(row: number, field: string | number): unknown;
}
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
return (field, row1, row2) => {
const get = dp.getValue?.bind(dp);
if (!get) return 0;
const val1 = get(row1, field);
const val2 = get(row2, field);
if (val1 == null && val2 == null) return 0;
if (val1 == null) return 1;
if (val2 == null) return -1;
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
if (isNaN(num1) && isNaN(num2)) return 0;
if (isNaN(num1)) return 1;
if (isNaN(num2)) return -1;
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
};
}
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
function toNum(v: unknown): number {
if (v == null) return NaN;
if (typeof v === "number") return isNaN(v) ? NaN : v;
return parseFloat(String(v).replace(/,/g, "")) || NaN;
}
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
function updateHeaderSortIndicator(
gridView: {
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
},
columns: { fieldName?: string; name: string }[],
sortDir: Record<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);
}
const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(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 activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
@@ -211,71 +444,58 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
const gridView = gridRef.current.gridView;
const dataProvider = gridRef.current.dataProvider;
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
const fieldMap = new Map<string, string>();
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
const hasMergeCells = columns.some((col) => col.mergeCells === true);
columns.forEach(col => {
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
fieldMap.set(col.fieldName, col.valueType);
fieldsToSet.push({
fieldName: col.fieldName,
dataType: col.valueType
});
}
});
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
if (fieldsToSet.length > 0) {
try {
// 기존 필드 정보 가져오기
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
const fieldsToUpdate = fieldsToSet.filter(field => {
const existingDataType = existingFieldMap.get(field.fieldName);
return existingDataType !== field.dataType;
});
if (fieldsToUpdate.length > 0) {
// 기존 필드 정보를 유지하면서 dataType만 업데이트
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
const allFields = existingFields.map(f => ({
fieldName: f.fieldName,
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
}));
// 새로 추가할 필드가 있으면 추가
fieldsToSet.forEach(field => {
if (!existingFieldMap.has(field.fieldName)) {
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
}
});
// setFields를 호출하여 필드의 dataType 업데이트
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
.setFields(allFields);
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
const dp = dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
const numberComparer = createNumberComparer(dp);
const seen = new Set<string>();
columns.forEach((col) => {
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
seen.add(col.fieldName);
try {
dp.setDataComparer!(col.fieldName, numberComparer);
} catch (e) {
console.warn("setDataComparer:", e);
}
}
} catch (error) {
console.warn('Failed to set field dataType:', error);
}
});
}
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
gridView.header.height = 60;
// 헤더 체크박스 표시 여부 설정
gridView.setOptions({
checkBar: { visible: showHeaderCheckbox },
rowIndicator: { visible: false },
});
// footer가 있는 컬럼이 있으면 footer 높이 설정
const hasFooter = columns.some(col => col.footer);
if (hasFooter) {
// multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasMultiRowFooter = columns.some(col => col.footer && col.footer.multiRow);
if (hasMultiRowFooter) {
gridView.setFooters?.([{height: 40}, {height: 40}]);
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
const hasMultiRowFooter = columns.some(col => {
const footer = col.footer;
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
});
if (hasArrayFooter || hasMultiRowFooter) {
// footer 배열의 길이를 확인하여 행 수 결정
const arrayFooterLengths = columns
.filter(col => col.footer && Array.isArray(col.footer))
.map(col => (col.footer as unknown[]).length);
const maxFooterLength = arrayFooterLengths.length > 0
? Math.max(...arrayFooterLengths)
: 2; // 기본값 2
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
gridView.setFooters?.(footerHeights);
} else {
gridView.footer.height = 40;
}
@@ -304,7 +524,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
});
};
if (checkFooterUserSpans(columnLayout) && gridView.setColumnLayout) {
if (checkFooterUserSpans(columnLayout)) {
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridRef.current?.gridView && columnLayout) {
@@ -313,18 +533,95 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
}, 100);
}
// footer의 valueCallback 설정
columns.forEach(col => {
if (col.footer && col.footer.valueCallback) {
const column = gridView.columnByName(col.name);
if (column && column.footer) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
// footer 설정 (배열 또는 단일 객체 모두 처리)
// setFooters 호출 후 footer 배열 설정
setTimeout(() => {
const currentGridView = gridRef.current?.gridView;
if (!currentGridView || !currentGridView.columnByName) {
console.warn('GridView not available for footer setup');
return;
}
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
if (hasArrayFooter) {
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
const columnsWithFooter = columns.map(col => {
const colInfo: any = {
name: col.name,
fieldName: col.fieldName,
header: col.header,
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
};
if (col.footer) {
if (Array.isArray(col.footer)) {
colInfo.footer = col.footer;
} else {
colInfo.footer = {
text: col.footer.text,
expression: col.footer.expression,
numberFormat: col.footer.numberFormat,
};
}
}
return colInfo;
});
try {
(currentGridView as any).setColumns?.(columnsWithFooter);
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
setTimeout(() => {
columns.forEach(col => {
if (!col.footer || !Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column) return;
// footer 배열의 각 요소에 valueCallback 설정
col.footer.forEach((footerItem, index) => {
if (footerItem.valueCallback && column.footer) {
// footer가 배열인 경우
if (Array.isArray(column.footer) && column.footer[index]) {
(column.footer[index] as any).valueCallback = footerItem.valueCallback;
}
}
});
} catch (e) {
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
}
});
}, 50);
} catch (e) {
console.warn('Failed to set columns with footer array:', e);
}
}
});
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
if (col.footer.valueCallback) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
}
} catch (e) {
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
}, 200);
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
return (
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<input
@@ -354,6 +651,9 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onInitialized={(grid) => {
(grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
}}
>
{columns.map((column) => (
<RGDataColumn
@@ -364,7 +664,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
header={column.header}
editable={column.editable}
footer={column.footer ? {
footer={column.footer && !Array.isArray(column.footer) ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
+458 -64
View File
@@ -24,6 +24,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 인터페이스 확장
declare global {
interface Window {
@@ -38,10 +48,252 @@ if (typeof window !== "undefined") {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
function toNum(v: unknown): number {
if (v == null) return NaN;
if (typeof v === "number") return isNaN(v) ? NaN : v;
return parseFloat(String(v).replace(/,/g, "")) || NaN;
}
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
function updateHeaderSortIndicator(
gridView: {
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
getElement?: () => HTMLElement | null;
container?: HTMLElement;
},
columns: { fieldName?: string; name: string }[],
sortDir: Record<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);
}
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
return (field, row1, row2) => {
const get = dp.getValue?.bind(dp);
if (!get) return 0;
const val1 = get(row1, field);
const val2 = get(row2, field);
if (val1 == null && val2 == null) return 0;
if (val1 == null) return 1;
if (val2 == null) return -1;
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
if (isNaN(num1) && isNaN(num2)) return 0;
if (isNaN(num1)) return 1;
if (isNaN(num2)) return -1;
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
};
}
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
// 각 탭별로 그리드 ref와 데이터를 관리
const gridRefs = useRef<Map<string, RealGridReact>>(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 activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
@@ -244,6 +496,30 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
}
}, [rows, columnInfo]);
// rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등)
useEffect(() => {
columnInfo.forEach((info) => {
const tabKey = info.tabKey;
const gridRef = gridRefs.current.get(tabKey);
const currentData = rowDataMap.get(tabKey);
if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) {
setTimeout(() => {
try {
const dp = gridRef.dataProvider as RealGridDataProviderFill | null;
if (dp?.fillJsonData) {
dp.fillJsonData(currentData, { fillMode: "set" });
}
gridRef.gridView.refresh();
} catch (error) {
console.error("그리드 데이터 설정 오류:", error);
}
}, 100);
}
});
}, [rowDataMap, columnInfo]);
// 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때)
useEffect(() => {
const gridRef = gridRefs.current.get(activeTab);
@@ -378,7 +654,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
</Button>
}
</div>
<div className="h-[60vh]">
<div className="h-[60vh]" data-tab-key={tabKey}>
<RealGridReact
ref={(el) => {
if (el) {
@@ -391,6 +667,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
if (!currentInfo || !currentInfo.columns) return;
const columns = currentInfo.columns;
gridView.setDisplayOptions({
fitStyle: GridFitStyle.EVEN
@@ -404,24 +682,140 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
});
// footer가 있는 컬럼이 있으면 footer 높이 설정
const hasFooter = currentInfo.columns.some(col => col.footer);
const hasFooter = columns.some(col => col.footer);
if (hasFooter) {
gridView.footer.height = 40;
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
const hasMultiRowFooter = columns.some(col => {
const footer = col.footer;
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
});
if (hasArrayFooter || hasMultiRowFooter) {
// footer 배열의 길이를 확인하여 행 수 결정
const arrayFooterLengths = columns
.filter(col => col.footer && Array.isArray(col.footer))
.map(col => (col.footer as unknown[]).length);
const maxFooterLength = arrayFooterLengths.length > 0
? Math.max(...arrayFooterLengths)
: 2; // 기본값 2
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
gridView.setFooters?.(footerHeights);
} else {
gridView.footer.height = 40;
}
}
gridView.setOptions({
summaryMode: "aggregate"
});
// footer의 valueCallback 설정
currentInfo.columns.forEach(col => {
if (col.footer && col.footer.valueCallback) {
const column = gridView.columnByName(col.name);
if (column && column.footer) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
const checkFooterUserSpans = (layout: unknown[]): boolean => {
return layout.some(item => {
if (typeof item === 'string') return false;
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
return checkFooterUserSpans(item.items);
}
return false;
});
};
if (checkFooterUserSpans(columnLayout)) {
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridView && columnLayout) {
gridView.setColumnLayout?.(columnLayout);
}
}, 100);
}
// footer 설정 (배열 또는 단일 객체 모두 처리)
// setFooters 호출 후 footer 배열 설정
setTimeout(() => {
const currentGridView = gridView;
if (!currentGridView || !currentGridView.columnByName) {
console.warn('GridView not available for footer setup');
return;
}
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
if (hasArrayFooter) {
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
const columnsWithFooter = columns.map(col => {
const colInfo: any = {
name: col.name,
fieldName: col.fieldName,
header: col.header,
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
};
if (col.footer) {
if (Array.isArray(col.footer)) {
colInfo.footer = col.footer;
} else {
colInfo.footer = {
text: col.footer.text,
expression: col.footer.expression,
numberFormat: col.footer.numberFormat,
};
}
}
return colInfo;
});
try {
(currentGridView as any).setColumns?.(columnsWithFooter);
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
setTimeout(() => {
columns.forEach(col => {
if (!col.footer || !Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column) return;
// footer 배열의 각 요소에 valueCallback 설정
col.footer.forEach((footerItem, index) => {
if (footerItem.valueCallback && column.footer) {
// footer가 배열인 경우
if (Array.isArray(column.footer) && column.footer[index]) {
(column.footer[index] as any).valueCallback = footerItem.valueCallback;
}
}
});
} catch (e) {
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
}
});
}, 50);
} catch (e) {
console.warn('Failed to set columns with footer array:', e);
}
}
});
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown };
if (footerObj.valueCallback) {
(column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback;
}
} catch (e) {
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
}, 200);
// 고정 컬럼 설정
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
@@ -430,60 +824,60 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
});
}
// valueType이 설정된 컬럼에 대해 필드의 dataType을 명시적으로 설정
// autoGenerateField={true}일 때 valueType prop만으로는 필드의 dataType이 설정되지 않을 수 있음
// 각 탭별 ref 초기화
if (!sortDirRefs.current.has(tabKey)) {
sortDirRefs.current.set(tabKey, { current: {} });
}
if (!originalRowsRefs.current.has(tabKey)) {
originalRowsRefs.current.set(tabKey, { current: {} });
}
const sortDirRef = sortDirRefs.current.get(tabKey)!;
const originalRowsRef = originalRowsRefs.current.get(tabKey)!;
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
const hasMergeCells = columns.some((col) => col.mergeCells === true);
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
if (el.dataProvider) {
const dataProvider = el.dataProvider;
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
const fieldMap = new Map<string, string>();
currentInfo.columns.forEach(col => {
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
fieldMap.set(col.fieldName, col.valueType);
fieldsToSet.push({
fieldName: col.fieldName,
dataType: col.valueType
});
}
});
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
if (fieldsToSet.length > 0) {
try {
// 기존 필드 정보 가져오기
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
const fieldsToUpdate = fieldsToSet.filter(field => {
const existingDataType = existingFieldMap.get(field.fieldName);
return existingDataType !== field.dataType;
});
if (fieldsToUpdate.length > 0) {
// 기존 필드 정보를 유지하면서 dataType만 업데이트
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
const allFields = existingFields.map(f => ({
fieldName: f.fieldName,
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
}));
// 새로 추가할 필드가 있으면 추가
fieldsToSet.forEach(field => {
if (!existingFieldMap.has(field.fieldName)) {
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
}
});
// setFields를 호출하여 필드의 dataType 업데이트
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
.setFields(allFields);
const dp = el.dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
const numberComparer = createNumberComparer(dp);
const seen = new Set<string>();
columns.forEach((col) => {
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
seen.add(col.fieldName);
try {
dp.setDataComparer!(col.fieldName, numberComparer);
} catch (e) {
console.warn("setDataComparer:", e);
}
}
} catch (error) {
console.warn('Failed to set field dataType:', error);
}
});
}
}
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
const setRowData = (updater: React.SetStateAction<Record<string, string | number>[]>) => {
setRowDataMap(prev => {
const newMap = new Map(prev);
const currentData = newMap.get(tabKey) || [];
const newData = typeof updater === 'function' ? updater(currentData) : updater;
newMap.set(tabKey, newData);
return newMap;
});
};
// 그리드 DOM 요소 찾기 (나중에 화살표 업데이트에 사용)
// 탭별로 그리드 요소를 찾기 위해 data-tab-key 사용
const findGridElement = () => {
const tabContent = document.querySelector(`[data-tab-key="${tabKey}"]`);
return tabContent?.querySelector('.rg-grid, [data-grid-id]') as HTMLElement | null;
};
const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement);
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = onSortingHandler;
}, 0);
}
} else {
@@ -503,10 +897,10 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
header={column.header}
editable={column.editable}
footer={column.footer ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
footer={column.footer && !Array.isArray(column.footer) ? {
text: (column.footer as { text?: string }).text,
expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression,
numberFormat: (column.footer as { numberFormat?: string }).numberFormat,
} : undefined}
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
sortable={!column.sortdisable}
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { DataGrid } from "@rsone/components";
import SubmitButton from "@/components/button/SumitButton";
@@ -49,7 +50,7 @@ const ExcretaTreatmentFacilityInp = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from "@rsone/components";
import SubmitButton from "@/components/button/SumitButton";
@@ -40,7 +41,7 @@ const PrivateSewageFacilityInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from 'react';
import SearchForm from '@/components/module/SearchForm';
import { DataGrid } from '@rsone/components';
import SubmitButton from '@/components/button/SumitButton';
@@ -43,7 +44,7 @@ const PublicSewageFacilityInp = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { DataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -49,7 +50,7 @@ const PumpingStationInp = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from "@rsone/components";
import SubmitButton from "@/components/button/SumitButton";
@@ -53,7 +54,7 @@ const SewageExcretaRelatedInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from "@rsone/components";
import SubmitButton from "@/components/button/SumitButton";
@@ -55,7 +56,7 @@ const SewageExcretaSlurgeInp = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -50,7 +51,7 @@ const SewagePipeInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -42,7 +43,7 @@ const SewagePipeMaintainInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -56,7 +57,7 @@ const SewageReservoirPumpInp = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -42,7 +43,7 @@ const SewerageFinanceInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,3 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { DataGrid } from "@rsone/components";
import SubmitButton from "@/components/button/SumitButton";
@@ -5,8 +7,6 @@ import SaveButton from "@/components/button/SaveButton";
import LastYearDataButton from "@/components/button/LastYearDataButton";
import SubmitExceptionButton from "@/components/button/SubmitExceptionButton";
import { useState } from "react";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
// 하수도 통계 관리 > 01. 하수도 보급률
@@ -32,7 +32,7 @@ const SeweragePenetrationRateInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] mx-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -40,7 +41,7 @@ const SewerageServiceLevelInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={submitExceptionFunc} />
@@ -1,8 +1,9 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { DataGrid } from "@rsone/components";
import ApproveButton from "@/components/button/ApproveButton";
import RejectButton from "@/components/button/RejectButton";
import { useState } from "react";
import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn";
@@ -21,7 +22,7 @@ const StatsInputStatusInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<RejectButton rejectFunc={rejectFunc} />
@@ -1,4 +1,5 @@
import { useState } from "react";
import SearchForm from "@/components/module/SearchForm";
import { TabDataGrid } from '@rsone/components';
import SubmitButton from "@/components/button/SumitButton";
@@ -62,7 +63,7 @@ const WaterReuseInp = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitExceptionButton SubmitExceptionFunc={SubmitExceptionFunc} />
@@ -32,7 +32,7 @@ const NotEnteredStatus = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<DataGrid columnInfo={{ columns, columnLayout }} rowData={rowData} setRowData={setRowData} isExcelImport={false} fixedColumns={2} rowStartNum={2} />
</>
);
@@ -30,7 +30,7 @@ const StatisticsInputManagement = () => {
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} />
</>
);
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const StatisticsPopulationManagement = () => {
return (
<>
<SearchForm userMenual={true} sgg={false} sido={false} />
<SearchForm sgg={false} sido={false} />
</>
);
};
@@ -30,7 +30,7 @@ const StatisticsReviewHistory = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<DataGrid columnInfo={{ columns, columnLayout }} rowStartNum={2} fixedColumns={3} rowData={rowData} setRowData={setRowData} isExcelImport={false} />
</>
);
@@ -21,7 +21,7 @@ const ExcretaTreatmentFacilityRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -35,7 +35,7 @@ const PrivateSewageFacilityRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -31,7 +31,7 @@ const PublicSewageFacilityRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -20,7 +20,7 @@ const PumpingStationRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -35,7 +35,7 @@ const SewageExcretaRelatedRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -35,7 +35,7 @@ const SewageExcretaSlurgeRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -9,7 +9,6 @@ import ReturnButton from "@/components/button/ReturnButton";
import { SewagePipeMaintainColumnInfoList } from "@/components/util/column/review/SewagePipeMaintainRvwColumn";
// 하수도 통계 관리 03. 하수관로 유지 관리
const SewagePipeMaintainRvw = () => {
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string | number>[]>>(new Map());
@@ -36,7 +35,7 @@ const SewagePipeMaintainRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -35,7 +35,7 @@ const SewagePipeRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -44,7 +44,7 @@ const SewagePipeRvw = () => {
<SaveButton saveFunc={saveFunc} />
</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 (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -36,7 +36,7 @@ const SewerageFinanceRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -1,7 +1,6 @@
import { useState } from "react";
// import { DataGrid } from "@rsone/components";
import DataGrid from "@/components/module/DataGrid";
import { DataGrid } from "@rsone/components";
import SearchForm from "@/components/module/SearchForm";
import SaveButton from "@/components/button/SaveButton";
import ApproveButton from "@/components/button/ApproveButton";
@@ -36,7 +35,7 @@ const SeweragePenetrationRateRvw = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -35,7 +35,7 @@ const SewerageServiceLevelRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
@@ -31,8 +31,8 @@ const StatsInputStatusRvw = () => {
return (
<>
<SearchForm userMenual={true} />
<div className="card !py-0">
<SearchForm />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
<ApproveButton approveFunc={approveFunc} />
@@ -40,13 +40,13 @@ const StatsInputStatusRvw = () => {
<SaveButton saveFunc={saveFunc} />
</div>
</div>
<DataGrid
columnInfo={StatsInputStatusInfoList}
isExcelImport={false}
rowStartNum={2}
fixedColumns={2}
rowData={rowData}
setRowData={setRowData} />
<DataGrid
columnInfo={StatsInputStatusInfoList}
isExcelImport={false}
rowStartNum={2}
fixedColumns={2}
rowData={rowData}
setRowData={setRowData} />
</>
);
}
@@ -34,15 +34,15 @@ const WaterReuseRvw = () => {
return (
<>
<SearchForm userMenual={true} fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
<ApproveButton approveFunc={approveFunc} />
<ReturnButton returnFunc={returnFunc} />
<SaveButton saveFunc={saveFunc} />
<SearchForm fctName={true} />
<div className="card !py-0">
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<ReviewOpinionButton reviewOpinionFunc={reviewOpinionFunc} />
<ApproveButton approveFunc={approveFunc} />
<ReturnButton returnFunc={returnFunc} />
<SaveButton saveFunc={saveFunc} />
</div>
</div>
</div>
<TabDataGrid columnInfo={WaterReuseInfoList} isExcelImport={false} rowDataMap={rowDataMap} setRowDataMap={setRowDataMap} />
</>
);
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const ExcretaTreatmentFacilityTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const PrivateSewageFacilityTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const PublicSewageFacilityTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const PumpingStationTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const SewageExcretaRelatedTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const SewageExcretaSlurgeTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const SewagePipeMaintainTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const SewagePipeTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const SewageReservoirPumpTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const SewerageFinanceTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -127,7 +127,7 @@ const SeweragePenetrationRateTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
<div className="flex justify-end items-center gap-[0.6rem] m-[0.6rem]">
<SubmitButton submitFunc={submitFunc} />
<SaveButton saveFunc={saveFunc} />
@@ -3,7 +3,7 @@ import SearchForm from "../../module/SearchForm";
const SewerageServiceLevelTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "../../module/SearchForm";
const WaterReuseTot = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
@@ -3,7 +3,7 @@ import SearchForm from "@/components/module/SearchForm";
const ManagementVerificationRule = () => {
return (
<>
<SearchForm userMenual={true} />
<SearchForm />
</>
);
};
+13 -1
View File
@@ -25,6 +25,12 @@ export type ColumnFooter = {
numberFormat?: string;
};
/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */
export type SortPreserveGroupOrder = {
groupField: string;
orderByField?: string;
};
export type ColumnConfig = {
name: string;
fieldName: string;
@@ -32,6 +38,7 @@ export type ColumnConfig = {
editable?: boolean; // 셀 편집 가능 여부
mergeCells?: boolean;
sortdisable?: boolean;
sortPreserveGroupOrder?: SortPreserveGroupOrder;
valueType?: ValueType;
dataFormat?: string;
footer?: {
@@ -40,7 +47,12 @@ export type ColumnConfig = {
numberFormat?: string;
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
multiRow?: boolean; // 여러 행 footer 사용 여부
},
} | Array<{
text?: string;
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
numberFormat?: string;
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
}>, // RealGrid에서 footer를 배열로 설정할 수 있음
};
export type FooterUserSpan = {
@@ -6,13 +6,13 @@ const rawList = [
{
tabKey: 'a',
tabLabel: '지역별 시설현황',
rowStartNum: 2,
fixedColumns: 1,
rowStartNum: 4,
fixedColumns: 2,
columns: [
/* 기본 정보 */
{ name: 'SIDO', fieldName: '시도', header: { text: '시도' } },
{ name: 'GUGUN', fieldName: '시군구', header: { text: '시군구' } },
{ name: 'SIDO', fieldName: '시도', header: { text: '시도' }, mergeCells: true },
{ name: 'GUGUN', fieldName: '시군구', header: { text: '시군구' }, mergeCells: true },
{ name: '구분', fieldName: '구분', header: { text: '구분' }, editable: false, sortdisable: true },
/* 하수정비 기본계획 */
{ name: 'TARGET_YR', fieldName: '하수정보기본계획목표년도', header: { text: '하수정보기본계획목표년도' } },
{ name: 'PIPL_IMPRMN_PLAN_EXTN', fieldName: '총관로정비 계획연장(m)', header: { text: '총관로정비\n계획연장(m)' }, valueType: ValueType.NUMBER, numberFormat: '#,##0.00' },
@@ -64,6 +64,7 @@ const rawList = [
],
columnLayout: [
{ name: 'areaGroup', header: { text: '행정구역' }, items: ['SIDO', 'GUGUN'] },
'구분',
'TARGET_YR', 'PIPL_IMPRMN_PLAN_EXTN', 'PIPL_PLAN_EXTN', 'PIPL_FCLTY_EXTN', 'PIPL_SUPPRT',
{
name: 'combinedGroup', header: { text: '합류식(m)' },
@@ -1,39 +1,175 @@
import { ValueType } from "realgrid";
import { footerInt, footerDecimal3 } from "@/components/util/grid/footerSummary";
import { footerInt, footerDecimal3, footerIntArrayByDivision, footerDecimal3ArrayByDivision } from "@/components/util/grid/footerSummary";
import type { GridView, DataColumn, ColumnFooter } from "@/components/type/DataGrid.type";
// 하수도 통계 관리 01. 하수도 보급률
export const SeweragePenetrationRateInfoList = {
columns: [
{ name: 'sido', fieldName: '시도', header: { text: '시도' }, mergeCells: true, editable: false, sortdisable: true, footer: { text: '합계', multiRow: true } },
{
name: 'sido',
fieldName: '시도',
header: { text: '시도' },
mergeCells: true,
editable: false,
sortdisable: true,
footer: [
{
valueCallback: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined
) => {
// 첫 번째 footer 행: 첫 번째 "구분" 값 + "합계"
const divisionIndex = 0;
const dataSource = grid.getDataSource();
const rowCount = dataSource.getRowCount();
const uniqueValues = new Set<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: '구분', 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);
console.log(`[구분 footer] footerIndex: ${footerIndex}, divisionIndex: ${divisionIndex}, divisionValues:`, divisionValues);
if (divisionValues.length > divisionIndex) {
const result = divisionValues[divisionIndex] + '합계';
console.log(`[구분 footer] result: ${result}`);
return result;
}
return '';
}
},
{
valueCallback: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: ColumnFooter,
value: number | string | undefined
) => {
// 두 번째 footer 행: 두 번째 "구분" 값 사용 (footerIndex는 1이어야 함)
const divisionIndex = 1; // 두 번째 footer 요소는 항상 두 번째 "구분" 값 사용
const dataSource = grid.getDataSource();
const rowCount = dataSource.getRowCount();
const uniqueValues = new Set<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);
console.log(`[구분 footer] footerIndex: ${footerIndex}, divisionIndex: ${divisionIndex}, divisionValues:`, divisionValues);
if (divisionValues.length > divisionIndex) {
const result = divisionValues[divisionIndex] + '합계';
console.log(`[구분 footer] result: ${result}`);
return result;
}
return '';
}
}
]
},
{ name: '수계', fieldName: '수계', header: { text: '수계' } },
{ name: 'bstrm', fieldName: '지류', header: { text: '지류' } },
{ name: 'mdbsn', fieldName: '세부단위유역', header: { text: '세부단위유역' } },
{ name: 'lgbsn', fieldName: '중권역', header: { text: '중권역' } },
{ name: 'mdmbsn', fieldName: '소권역', header: { text: '소권역' } },
{ name: 'specl_cntrpln_ar', fieldName: '특별대책지역', header: { text: '특별대책지역' } },
{ name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, footer: footerInt, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, footer: footerDecimal3, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
{ name: '계', fieldName: '계', header: { text: '계' } },
{ name: 'total_popl', fieldName: '총인구', header: { text: '총인구' }, footer: footerIntArrayByDivision, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'ar_sm', fieldName: '총면적(㎢)', header: { text: '총면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
{ name: '계', fieldName: '계', header: { text: '계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
//공공하수처리인구
{ name: '합계', fieldName: '합계', header: { text: '합계' } },
{ name: '1차처리', fieldName: '1차처리', header: { text: '1차처리' } },
{ name: '2차처리', fieldName: '2차처리', header: { text: '2차처리' } },
{ name: '3차처리', fieldName: '3차처리', header: { text: '3차처리' } },
{ name: '합계', fieldName: '합계', header: { text: '합계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: '1차처리', fieldName: '1차처리', header: { text: '1차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: '2차처리', fieldName: '2차처리', header: { text: '2차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: '3차처리', fieldName: '3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
//폐수처리인구(명)
{ name: '(폐수처리인구)합계', fieldName: '(폐수처리인구)합계', header: { text: '합계' } },
{ name: '(폐수처리인구)1차처리', fieldName: '(폐수처리인구)1차처리', header: { text: '1차처리' } },
{ name: '(폐수처리인구)2차처리', fieldName: '(폐수처리인구)2차처리', header: { text: '2차처리' } },
{ name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' } },
{ name: '(폐수처리인구)합계', fieldName: '(폐수처리인구)합계', header: { text: '합계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: '(폐수처리인구)1차처리', fieldName: '(폐수처리인구)1차처리', header: { text: '1차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: '(폐수처리인구)2차처리', fieldName: '(폐수처리인구)2차처리', header: { text: '2차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: '(폐수처리인구)3차처리', fieldName: '(폐수처리인구)3차처리', header: { text: '3차처리' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' } },
{ name: 'swge_proc_zone_inner_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' } },
{ name: 'swge_proc_zone_outer_sm', fieldName: '합계', header: { text: '계' } },
{ name: 'town_unproc_popltn', fieldName: '미처리인구', header: { text: '미처리인구' } },
{ name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' } },
{ name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' } },
{ name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' } },
{ name: 'not_conect_popltn', fieldName: '미접속인구', header: { text: '미접속인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'swge_proc_zone_inner_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
{ name: 'swge_proc_zone_outer_sm', fieldName: '합계', header: { text: '계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'town_unproc_popltn', fieldName: '미처리인구', header: { text: '미처리인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'town_swtt_popltn', fieldName: '오수처리인구', header: { text: '오수처리인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'town_wrrtn_popltn', fieldName: '정화조인구', header: { text: '정화조인구' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'swge_proc_zone_outer_ar', fieldName: '면적(㎢)', header: { text: '면적(㎢)' }, footer: footerDecimal3ArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0.000' },
{ name: 'pblswrg_popltn_suprt', fieldName: '공공하수처리구역인구보급율(%)', header: { text: '공공하수처리구역인구보급율(%)' } },
{ name: 'adtrt_popltn_suprt', fieldName: '고도처리인구보급율(%)', header: { text: `고도처리인구보급율(%)` } },
{ name: 'swrg_instlrt', fieldName: '하수도설치율(%)', header: { text: '하수도설치율(%)' } },
@@ -45,7 +181,7 @@ export const SeweragePenetrationRateInfoList = {
hideChildHeaders: false,
header: { text: '행정구역' },
items: [
{ column: 'sido', footerUserSpans: [{ colspan: 3 }] },
{ column: 'sido', footerUserSpans: [{ colspan: 3 }, { colspan: 3 }] }, // 첫 번째 footer 행과 두 번째 footer 행 모두 병합
'sgg',
]
},
@@ -23,6 +23,65 @@ export const getFooterSummary = (grid: GridView, column: DataColumn) => {
return sum;
};
// "구분" 필드의 고유값들을 찾는 함수 (첫 번째, 두 번째)
const getDivisionValues = (grid: GridView, divisionFieldName: string = '구분'): string[] => {
const dataSource = grid.getDataSource();
const rowCount = dataSource.getRowCount();
const uniqueValues = new Set<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 설정 객체 생성 함수
const createSumFooter = (format: string) => ({
expression: 'sum' as const,
@@ -32,6 +91,38 @@ const createSumFooter = (format: string) => ({
},
});
// "구분" 값에 따라 필터링하는 Footer 배열 생성 함수 (2줄)
const createSumFooterArrayByDivision = (format: string) => [
{
numberFormat: format,
valueCallback: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: unknown,
value: number | string | undefined
) => {
// footerIndex: 0 = 첫 번째 footer 행 (첫 번째 "구분" 값), 1 = 두 번째 footer 행 (두 번째 "구분" 값)
// footerIndex를 divisionIndex로 사용
return getFooterSummaryByDivision(grid, column, footerIndex);
},
},
{
numberFormat: format,
valueCallback: (
grid: GridView,
column: DataColumn,
footerIndex: number,
columnFooter: unknown,
value: number | string | undefined
) => {
// footerIndex: 0 = 첫 번째 footer 행 (첫 번째 "구분" 값), 1 = 두 번째 footer 행 (두 번째 "구분" 값)
// footerIndex를 divisionIndex로 사용
return getFooterSummaryByDivision(grid, column, footerIndex);
},
},
];
// 1. 정수형 : #,##0
export const footerInt = createSumFooter('#,##0');
@@ -42,4 +133,10 @@ export const footerDecimal1 = createSumFooter('#,##0.0');
export const footerDecimal2 = createSumFooter('#,##0.00');
// 4. 소수점 3자리 : #,##0.000
export const footerDecimal3 = createSumFooter('#,##0.000');
export const footerDecimal3 = createSumFooter('#,##0.000');
// "구분" 값에 따라 필터링하는 Footer 배열 (정수형, 2줄)
export const footerIntArrayByDivision = createSumFooterArrayByDivision('#,##0');
// "구분" 값에 따라 필터링하는 Footer 배열 (소수점 3자리, 2줄)
export const footerDecimal3ArrayByDivision = createSumFooterArrayByDivision('#,##0.000');
+4 -1
View File
@@ -23,4 +23,7 @@ dist-ssr
*.sln
*.sw?
.env*
.env*
# TypeScript
*.tsbuildinfo
@@ -18,6 +18,31 @@ declare module "realgrid" {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
interface RealGridDataProviderComparer {
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
getValue?(row: number, field: string | number): unknown;
}
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
return (field, row1, row2) => {
const get = dp.getValue?.bind(dp);
if (!get) return 0;
const val1 = get(row1, field);
const val2 = get(row2, field);
if (val1 == null && val2 == null) return 0;
if (val1 == null) return 1;
if (val2 == null) return -1;
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
if (isNaN(num1) && isNaN(num2)) return 0;
if (isNaN(num1)) return 1;
if (isNaN(num2)) return -1;
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
};
}
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -28,54 +53,21 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
const gridView = gridRef.current.gridView;
const dataProvider = gridRef.current.dataProvider;
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
const fieldMap = new Map<string, string>();
columns.forEach(col => {
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
fieldMap.set(col.fieldName, col.valueType);
fieldsToSet.push({
fieldName: col.fieldName,
dataType: col.valueType
});
}
});
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
if (fieldsToSet.length > 0) {
try {
// 기존 필드 정보 가져오기
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
const fieldsToUpdate = fieldsToSet.filter(field => {
const existingDataType = existingFieldMap.get(field.fieldName);
return existingDataType !== field.dataType;
});
if (fieldsToUpdate.length > 0) {
// 기존 필드 정보를 유지하면서 dataType만 업데이트
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
const allFields = existingFields.map(f => ({
fieldName: f.fieldName,
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
}));
// 새로 추가할 필드가 있으면 추가
fieldsToSet.forEach(field => {
if (!existingFieldMap.has(field.fieldName)) {
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
}
});
// setFields를 호출하여 필드의 dataType 업데이트
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
.setFields(allFields);
// setDataComparer로 숫자 정렬 (setFields 미사용 → 데이터 유실·필드 중복 오류 없음)
const dp = dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
const numberComparer = createNumberComparer(dp);
const seen = new Set<string>();
columns.forEach((col) => {
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
seen.add(col.fieldName);
try {
dp.setDataComparer!(col.fieldName, numberComparer);
} catch (e) {
console.warn("setDataComparer:", e);
}
}
} catch (error) {
console.warn('Failed to set field dataType:', error);
}
});
}
gridView.setDisplayOptions({
@@ -92,7 +84,25 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
// footer가 있는 컬럼이 있으면 footer 높이 설정
const hasFooter = columns.some(col => col.footer);
if (hasFooter) {
gridView.footer.height = 40;
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
const hasMultiRowFooter = columns.some(col => {
const footer = col.footer;
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
});
if (hasArrayFooter || hasMultiRowFooter) {
// footer 배열의 길이를 확인하여 행 수 결정
const arrayFooterLengths = columns
.filter(col => col.footer && Array.isArray(col.footer))
.map(col => (col.footer as unknown[]).length);
const maxFooterLength = arrayFooterLengths.length > 0
? Math.max(...arrayFooterLengths)
: 2; // 기본값 2
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
gridView.setFooters?.(footerHeights);
} else {
gridView.footer.height = 40;
}
}
// 고정 컬럼 설정
@@ -106,15 +116,112 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
summaryMode: "aggregate"
});
// footer의 valueCallback 설정
columns.forEach(col => {
if (col.footer && col.footer.valueCallback) {
const column = gridView.columnByName(col.name);
if (column && column.footer) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
const checkFooterUserSpans = (layout: unknown[]): boolean => {
return layout.some(item => {
if (typeof item === 'string') return false;
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
return checkFooterUserSpans(item.items);
}
return false;
});
};
if (checkFooterUserSpans(columnLayout)) {
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridRef.current?.gridView && columnLayout) {
gridRef.current.gridView.setColumnLayout?.(columnLayout);
}
}, 100);
}
// footer 설정 (배열 또는 단일 객체 모두 처리)
// setFooters 호출 후 footer 배열 설정
setTimeout(() => {
const currentGridView = gridRef.current?.gridView;
if (!currentGridView || !currentGridView.columnByName) {
console.warn('GridView not available for footer setup');
return;
}
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
if (hasArrayFooter) {
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
const columnsWithFooter = columns.map(col => {
const colInfo: any = {
name: col.name,
fieldName: col.fieldName,
header: col.header,
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
};
if (col.footer) {
if (Array.isArray(col.footer)) {
colInfo.footer = col.footer;
} else {
colInfo.footer = {
text: col.footer.text,
expression: col.footer.expression,
numberFormat: col.footer.numberFormat,
};
}
}
return colInfo;
});
try {
(currentGridView as any).setColumns?.(columnsWithFooter);
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
setTimeout(() => {
columns.forEach(col => {
if (!col.footer || !Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column) return;
// footer 배열의 각 요소에 valueCallback 설정
col.footer.forEach((footerItem, index) => {
if (footerItem.valueCallback && column.footer) {
// footer가 배열인 경우
if (Array.isArray(column.footer) && column.footer[index]) {
(column.footer[index] as any).valueCallback = footerItem.valueCallback;
}
}
});
} catch (e) {
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
}
});
}, 50);
} catch (e) {
console.warn('Failed to set columns with footer array:', e);
}
}
});
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
if (col.footer.valueCallback) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
}
} catch (e) {
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
}, 200);
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
@@ -136,7 +243,7 @@ const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false,
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
header={column.header}
editable={column.editable}
footer={column.footer ? {
footer={column.footer && !Array.isArray(column.footer) ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
+393 -62
View File
@@ -6,8 +6,10 @@ import { RealGridReact, RGDataColumn } from "realgrid-react"
import type { DataGridProps } from "../type/DataGrid.type"
import JSZip from "jszip"
import * as XLSX from "xlsx"
import { Button } from "@common/components";
import { useAlertStore } from "../store/alert-store";
import { Button } from "@common/components";
import { useTabStore } from "../store/tab-store";
// RealGrid 타입 확장
@@ -35,10 +37,245 @@ if (typeof window !== "undefined") {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
interface RealGridDataProviderComparer {
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
getValue?(row: number, field: string | number): unknown;
}
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
return (field, row1, row2) => {
const get = dp.getValue?.bind(dp);
if (!get) return 0;
const val1 = get(row1, field);
const val2 = get(row2, field);
if (val1 == null && val2 == null) return 0;
if (val1 == null) return 1;
if (val2 == null) return -1;
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
if (isNaN(num1) && isNaN(num2)) return 0;
if (isNaN(num1)) return 1;
if (isNaN(num2)) return -1;
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
};
}
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
function toNum(v: unknown): number {
if (v == null) return NaN;
if (typeof v === "number") return isNaN(v) ? NaN : v;
return parseFloat(String(v).replace(/,/g, "")) || NaN;
}
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
function updateHeaderSortIndicator(
gridView: {
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
},
columns: { fieldName?: string; name: string }[],
sortDir: Record<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);
}
const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(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 activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
@@ -208,68 +445,61 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
const gridView = gridRef.current.gridView;
const dataProvider = gridRef.current.dataProvider;
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
const fieldMap = new Map<string, string>();
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
const hasMergeCells = columns.some((col) => col.mergeCells === true);
columns.forEach(col => {
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
fieldMap.set(col.fieldName, col.valueType);
fieldsToSet.push({
fieldName: col.fieldName,
dataType: col.valueType
});
}
});
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
if (fieldsToSet.length > 0) {
try {
// 기존 필드 정보 가져오기
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
const fieldsToUpdate = fieldsToSet.filter(field => {
const existingDataType = existingFieldMap.get(field.fieldName);
return existingDataType !== field.dataType;
});
if (fieldsToUpdate.length > 0) {
// 기존 필드 정보를 유지하면서 dataType만 업데이트
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
const allFields = existingFields.map(f => ({
fieldName: f.fieldName,
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
}));
// 새로 추가할 필드가 있으면 추가
fieldsToSet.forEach(field => {
if (!existingFieldMap.has(field.fieldName)) {
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
}
});
// setFields를 호출하여 필드의 dataType 업데이트
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
.setFields(allFields);
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
const dp = dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
const numberComparer = createNumberComparer(dp);
const seen = new Set<string>();
columns.forEach((col) => {
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
seen.add(col.fieldName);
try {
dp.setDataComparer!(col.fieldName, numberComparer);
} catch (e) {
console.warn("setDataComparer:", e);
}
}
} catch (error) {
console.warn('Failed to set field dataType:', error);
}
});
}
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
gridView.header.height = 60;
// 헤더 체크박스 표시 여부 설정
gridView.setOptions({
checkBar: { visible: showHeaderCheckbox },
rowIndicator: { visible: false },
});
// footer가 있는 컬럼이 있으면 footer 높이 설정
const hasFooter = columns.some(col => col.footer);
if (hasFooter) {
gridView.footer.height = 40;
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
const hasMultiRowFooter = columns.some(col => {
const footer = col.footer;
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
});
if (hasArrayFooter || hasMultiRowFooter) {
// footer 배열의 길이를 확인하여 행 수 결정
const arrayFooterLengths = columns
.filter(col => col.footer && Array.isArray(col.footer))
.map(col => (col.footer as unknown[]).length);
const maxFooterLength = arrayFooterLengths.length > 0
? Math.max(...arrayFooterLengths)
: 2; // 기본값 2
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
gridView.setFooters?.(footerHeights);
} else {
gridView.footer.height = 40;
}
}
// 고정 컬럼 설정
@@ -283,18 +513,116 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
summaryMode: "aggregate"
});
// footer의 valueCallback 설정
columns.forEach(col => {
if (col.footer && col.footer.valueCallback) {
const column = gridView.columnByName(col.name);
if (column && column.footer) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
const checkFooterUserSpans = (layout: unknown[]): boolean => {
return layout.some(item => {
if (typeof item === 'string') return false;
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
return checkFooterUserSpans(item.items);
}
return false;
});
};
if (checkFooterUserSpans(columnLayout)) {
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridRef.current?.gridView && columnLayout) {
gridRef.current.gridView.setColumnLayout?.(columnLayout);
}
}, 100);
}
// footer 설정 (배열 또는 단일 객체 모두 처리)
// setFooters 호출 후 footer 배열 설정
setTimeout(() => {
const currentGridView = gridRef.current?.gridView;
if (!currentGridView || !currentGridView.columnByName) {
console.warn('GridView not available for footer setup');
return;
}
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
if (hasArrayFooter) {
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
const columnsWithFooter = columns.map(col => {
const colInfo: any = {
name: col.name,
fieldName: col.fieldName,
header: col.header,
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
};
if (col.footer) {
if (Array.isArray(col.footer)) {
colInfo.footer = col.footer;
} else {
colInfo.footer = {
text: col.footer.text,
expression: col.footer.expression,
numberFormat: col.footer.numberFormat,
};
}
}
return colInfo;
});
try {
(currentGridView as any).setColumns?.(columnsWithFooter);
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
setTimeout(() => {
columns.forEach(col => {
if (!col.footer || !Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column) return;
// footer 배열의 각 요소에 valueCallback 설정
col.footer.forEach((footerItem, index) => {
if (footerItem.valueCallback && column.footer) {
// footer가 배열인 경우
if (Array.isArray(column.footer) && column.footer[index]) {
(column.footer[index] as any).valueCallback = footerItem.valueCallback;
}
}
});
} catch (e) {
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
}
});
}, 50);
} catch (e) {
console.warn('Failed to set columns with footer array:', e);
}
}
});
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
if (col.footer.valueCallback) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
}
} catch (e) {
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
}, 200);
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox]);
return (
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<input
@@ -319,12 +647,15 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
}
</div>
<div className="h-[65vh]">
<RealGridReact
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
>
<RealGridReact
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onInitialized={(grid) => {
(grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
}}
>
{columns.map((column) => (
<RGDataColumn
key={column.name}
@@ -334,7 +665,7 @@ const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
header={column.header}
editable={column.editable}
footer={column.footer ? {
footer={column.footer && !Array.isArray(column.footer) ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
+458 -64
View File
@@ -24,6 +24,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 인터페이스 확장
declare global {
interface Window {
@@ -38,10 +48,252 @@ if (typeof window !== "undefined") {
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
function toNum(v: unknown): number {
if (v == null) return NaN;
if (typeof v === "number") return isNaN(v) ? NaN : v;
return parseFloat(String(v).replace(/,/g, "")) || NaN;
}
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
function updateHeaderSortIndicator(
gridView: {
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
getElement?: () => HTMLElement | null;
container?: HTMLElement;
},
columns: { fieldName?: string; name: string }[],
sortDir: Record<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);
}
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
return (field, row1, row2) => {
const get = dp.getValue?.bind(dp);
if (!get) return 0;
const val1 = get(row1, field);
const val2 = get(row2, field);
if (val1 == null && val2 == null) return 0;
if (val1 == null) return 1;
if (val2 == null) return -1;
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
if (isNaN(num1) && isNaN(num2)) return 0;
if (isNaN(num1)) return 1;
if (isNaN(num2)) return -1;
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
};
}
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
// 각 탭별로 그리드 ref와 데이터를 관리
const gridRefs = useRef<Map<string, RealGridReact>>(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 activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
@@ -244,6 +496,30 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
}
}, [rows, columnInfo]);
// rowDataMap이 변경될 때 그리드에 데이터 반영 (Excel import 등)
useEffect(() => {
columnInfo.forEach((info) => {
const tabKey = info.tabKey;
const gridRef = gridRefs.current.get(tabKey);
const currentData = rowDataMap.get(tabKey);
if (gridRef?.gridView && gridRef?.dataProvider && currentData && currentData.length > 0) {
setTimeout(() => {
try {
const dp = gridRef.dataProvider as RealGridDataProviderFill | null;
if (dp?.fillJsonData) {
dp.fillJsonData(currentData, { fillMode: "set" });
}
gridRef.gridView.refresh();
} catch (error) {
console.error("그리드 데이터 설정 오류:", error);
}
}, 100);
}
});
}, [rowDataMap, columnInfo]);
// 활성 탭이 변경될 때마다 fitStyle 재적용 (그리드가 보일 때)
useEffect(() => {
const gridRef = gridRefs.current.get(activeTab);
@@ -378,7 +654,7 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
</Button>
}
</div>
<div className="h-[60vh]">
<div className="h-[60vh]" data-tab-key={tabKey}>
<RealGridReact
ref={(el) => {
if (el) {
@@ -391,6 +667,8 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
if (!currentInfo || !currentInfo.columns) return;
const columns = currentInfo.columns;
gridView.setDisplayOptions({
fitStyle: GridFitStyle.EVEN
@@ -404,24 +682,140 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
});
// footer가 있는 컬럼이 있으면 footer 높이 설정
const hasFooter = currentInfo.columns.some(col => col.footer);
const hasFooter = columns.some(col => col.footer);
if (hasFooter) {
gridView.footer.height = 40;
// footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행)
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
const hasMultiRowFooter = columns.some(col => {
const footer = col.footer;
return footer && !Array.isArray(footer) && 'multiRow' in footer && footer.multiRow === true;
});
if (hasArrayFooter || hasMultiRowFooter) {
// footer 배열의 길이를 확인하여 행 수 결정
const arrayFooterLengths = columns
.filter(col => col.footer && Array.isArray(col.footer))
.map(col => (col.footer as unknown[]).length);
const maxFooterLength = arrayFooterLengths.length > 0
? Math.max(...arrayFooterLengths)
: 2; // 기본값 2
const footerHeights = Array.from({ length: maxFooterLength }, () => ({ height: 40 }));
gridView.setFooters?.(footerHeights);
} else {
gridView.footer.height = 40;
}
}
gridView.setOptions({
summaryMode: "aggregate"
});
// footer의 valueCallback 설정
currentInfo.columns.forEach(col => {
if (col.footer && col.footer.valueCallback) {
const column = gridView.columnByName(col.name);
if (column && column.footer) {
(column.footer as { valueCallback?: typeof col.footer.valueCallback }).valueCallback = col.footer.valueCallback;
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
const checkFooterUserSpans = (layout: unknown[]): boolean => {
return layout.some(item => {
if (typeof item === 'string') return false;
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
return checkFooterUserSpans(item.items);
}
return false;
});
};
if (checkFooterUserSpans(columnLayout)) {
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
setTimeout(() => {
if (gridView && columnLayout) {
gridView.setColumnLayout?.(columnLayout);
}
}, 100);
}
// footer 설정 (배열 또는 단일 객체 모두 처리)
// setFooters 호출 후 footer 배열 설정
setTimeout(() => {
const currentGridView = gridView;
if (!currentGridView || !currentGridView.columnByName) {
console.warn('GridView not available for footer setup');
return;
}
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
if (hasArrayFooter) {
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
const columnsWithFooter = columns.map(col => {
const colInfo: any = {
name: col.name,
fieldName: col.fieldName,
header: col.header,
editable: col.editable,
valueType: col.valueType,
dataFormat: col.dataFormat,
};
if (col.footer) {
if (Array.isArray(col.footer)) {
colInfo.footer = col.footer;
} else {
colInfo.footer = {
text: col.footer.text,
expression: col.footer.expression,
numberFormat: col.footer.numberFormat,
};
}
}
return colInfo;
});
try {
(currentGridView as any).setColumns?.(columnsWithFooter);
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
setTimeout(() => {
columns.forEach(col => {
if (!col.footer || !Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column) return;
// footer 배열의 각 요소에 valueCallback 설정
col.footer.forEach((footerItem, index) => {
if (footerItem.valueCallback && column.footer) {
// footer가 배열인 경우
if (Array.isArray(column.footer) && column.footer[index]) {
(column.footer[index] as any).valueCallback = footerItem.valueCallback;
}
}
});
} catch (e) {
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
}
});
}, 50);
} catch (e) {
console.warn('Failed to set columns with footer array:', e);
}
}
});
// footer가 단일 객체이고 valueCallback이 있는 경우
columns.forEach(col => {
if (!col.footer || Array.isArray(col.footer)) return;
try {
const column = currentGridView.columnByName(col.name);
if (!column || !column.footer) return;
const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown };
if (footerObj.valueCallback) {
(column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback;
}
} catch (e) {
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
}
});
}, 200);
// 고정 컬럼 설정
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
@@ -430,60 +824,60 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
});
}
// valueType이 설정된 컬럼에 대해 필드의 dataType을 명시적으로 설정
// autoGenerateField={true}일 때 valueType prop만으로는 필드의 dataType이 설정되지 않을 수 있음
// 각 탭별 ref 초기화
if (!sortDirRefs.current.has(tabKey)) {
sortDirRefs.current.set(tabKey, { current: {} });
}
if (!originalRowsRefs.current.has(tabKey)) {
originalRowsRefs.current.set(tabKey, { current: {} });
}
const sortDirRef = sortDirRefs.current.get(tabKey)!;
const originalRowsRef = originalRowsRefs.current.get(tabKey)!;
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
const hasMergeCells = columns.some((col) => col.mergeCells === true);
// setDataComparer로 숫자 정렬 (mergeCells가 있으면 onSorting에서 클라이언트 정렬로 처리)
if (el.dataProvider) {
const dataProvider = el.dataProvider;
const fieldsToSet: Array<{ fieldName: string; dataType: string }> = [];
const fieldMap = new Map<string, string>();
currentInfo.columns.forEach(col => {
if (col.valueType && col.fieldName && !fieldMap.has(col.fieldName)) {
fieldMap.set(col.fieldName, col.valueType);
fieldsToSet.push({
fieldName: col.fieldName,
dataType: col.valueType
});
}
});
// 필드가 이미 존재하는지 확인하고, valueType이 설정된 필드만 업데이트
if (fieldsToSet.length > 0) {
try {
// 기존 필드 정보 가져오기
const existingFields = (dataProvider as { getFields?: () => Array<{ fieldName: string; dataType?: string }> }).getFields?.() || [];
const existingFieldMap = new Map(existingFields.map(f => [f.fieldName, f.dataType || 'text']));
// valueType이 설정된 필드 중 dataType이 다른 필드만 업데이트
const fieldsToUpdate = fieldsToSet.filter(field => {
const existingDataType = existingFieldMap.get(field.fieldName);
return existingDataType !== field.dataType;
});
if (fieldsToUpdate.length > 0) {
// 기존 필드 정보를 유지하면서 dataType만 업데이트
// 컬럼 정의에 valueType이 있으면 그것을 우선 사용, 없으면 기존 필드의 dataType 사용
const allFields = existingFields.map(f => ({
fieldName: f.fieldName,
dataType: fieldMap.get(f.fieldName) || f.dataType || 'text'
}));
// 새로 추가할 필드가 있으면 추가
fieldsToSet.forEach(field => {
if (!existingFieldMap.has(field.fieldName)) {
allFields.push({ fieldName: field.fieldName, dataType: field.dataType });
}
});
// setFields를 호출하여 필드의 dataType 업데이트
(dataProvider as { setFields: (fields: Array<{ fieldName: string; dataType: string }>) => void })
.setFields(allFields);
const dp = el.dataProvider as RealGridDataProviderComparer;
if (typeof dp.setDataComparer === "function" && typeof dp.getValue === "function") {
const numberComparer = createNumberComparer(dp);
const seen = new Set<string>();
columns.forEach((col) => {
if (hasMergeCells) return; // mergeCells가 있으면 모든 컬럼을 클라이언트 정렬로 처리
if (col.valueType === ValueType.NUMBER && col.fieldName && !seen.has(col.fieldName)) {
seen.add(col.fieldName);
try {
dp.setDataComparer!(col.fieldName, numberComparer);
} catch (e) {
console.warn("setDataComparer:", e);
}
}
} catch (error) {
console.warn('Failed to set field dataType:', error);
}
});
}
}
// mergeCells: true인 컬럼이 있으면 onSorting에서 그룹 내 순서 유지 정렬 적용
const setRowData = (updater: React.SetStateAction<Record<string, string | number>[]>) => {
setRowDataMap(prev => {
const newMap = new Map(prev);
const currentData = newMap.get(tabKey) || [];
const newData = typeof updater === 'function' ? updater(currentData) : updater;
newMap.set(tabKey, newData);
return newMap;
});
};
// 그리드 DOM 요소 찾기 (나중에 화살표 업데이트에 사용)
// 탭별로 그리드 요소를 찾기 위해 data-tab-key 사용
const findGridElement = () => {
const tabContent = document.querySelector(`[data-tab-key="${tabKey}"]`);
return tabContent?.querySelector('.rg-grid, [data-grid-id]') as HTMLElement | null;
};
const onSortingHandler = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef, findGridElement);
(gridView as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = onSortingHandler;
}, 0);
}
} else {
@@ -503,10 +897,10 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
header={column.header}
editable={column.editable}
footer={column.footer ? {
text: column.footer.text,
expression: column.footer.expression,
numberFormat: column.footer.numberFormat,
footer={column.footer && !Array.isArray(column.footer) ? {
text: (column.footer as { text?: string }).text,
expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression,
numberFormat: (column.footer as { numberFormat?: string }).numberFormat,
} : undefined}
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
sortable={!column.sortdisable}
@@ -25,6 +25,12 @@ export type ColumnFooter = {
numberFormat?: string;
};
/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */
export type SortPreserveGroupOrder = {
groupField: string;
orderByField?: string;
};
export type ColumnConfig = {
name: string;
fieldName: string;
@@ -32,6 +38,7 @@ export type ColumnConfig = {
editable?: boolean; // 셀 편집 가능 여부
mergeCells?: boolean;
sortdisable?: boolean;
sortPreserveGroupOrder?: SortPreserveGroupOrder;
valueType?: ValueType;
dataFormat?: string;
footer?: {
@@ -39,7 +46,19 @@ export type ColumnConfig = {
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
numberFormat?: string;
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
},
multiRow?: boolean; // 여러 행 footer 사용 여부
} | Array<{
text?: string;
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
numberFormat?: string;
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
}>, // RealGrid에서 footer를 배열로 설정할 수 있음
};
export type FooterUserSpan = {
rowspan?: number; // 병합할 행 수
colspan?: number; // 병합할 컬럼 수
text?: string; // 표시할 텍스트
};
export type ColumnLayoutItem = string | {
@@ -47,6 +66,10 @@ export type ColumnLayoutItem = string | {
hideChildHeaders?: boolean;
header: { text: string };
items: (string | ColumnLayoutItem)[];
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
} | {
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
};
export type ColumnInfo = {