Merge branch 'feature/review-skeleton-sein' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { ValueType } from "realgrid";
|
||||
|
||||
import RealGrid, { GridFitStyle } from "realgrid";
|
||||
import { RealGridReact, RGDataColumn } from "realgrid-react";
|
||||
import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type";
|
||||
import { getComputedValueCallback } from "@/type/computedFieldCallbacks";
|
||||
|
||||
// RealGrid 타입 확장
|
||||
declare module "realgrid" {
|
||||
interface GridView {
|
||||
setOptions(options: {
|
||||
footer?: { visible?: boolean };
|
||||
summaryMode?: 'aggregate' | 'statistical' | 'none';
|
||||
checkBar?: { visible?: boolean };
|
||||
}): void;
|
||||
}
|
||||
}
|
||||
|
||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||
|
||||
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||
interface RealGridDataProviderComparer {
|
||||
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||
getValue?(row: number, field: string | number): unknown;
|
||||
}
|
||||
|
||||
/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */
|
||||
function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) {
|
||||
return columns.map((col) => {
|
||||
const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = {
|
||||
fieldName: col.fieldName,
|
||||
dataType: col.valueType === ValueType.NUMBER ? "number" : "text",
|
||||
};
|
||||
if (col.computed) {
|
||||
const hasFields = "fields" in col.computed && col.computed.fields.length > 0;
|
||||
const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0;
|
||||
if (hasFields || hasNumeratorDenominator) {
|
||||
field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number;
|
||||
}
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}
|
||||
|
||||
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
|
||||
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
|
||||
return (field, row1, row2) => {
|
||||
const get = dp.getValue?.bind(dp);
|
||||
if (!get) return 0;
|
||||
const val1 = get(row1, field);
|
||||
const val2 = get(row2, field);
|
||||
if (val1 == null && val2 == null) return 0;
|
||||
if (val1 == null) return 1;
|
||||
if (val2 == null) return -1;
|
||||
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||
if (isNaN(num1)) return 1;
|
||||
if (isNaN(num2)) return -1;
|
||||
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
const CompactDataGrid = ({ columnInfo, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
||||
const { columns, columnLayout } = columnInfo;
|
||||
const gridRef = useRef<RealGridReact>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current?.gridView || !gridRef.current?.dataProvider) return;
|
||||
|
||||
const gridView = gridRef.current.gridView;
|
||||
const dataProvider = gridRef.current.dataProvider;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
gridView.setDisplayOptions({
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 고정 컬럼 설정
|
||||
if (fixedColumns !== undefined && fixedColumns > 0) {
|
||||
gridView.setFixedOptions({
|
||||
colCount: fixedColumns,
|
||||
});
|
||||
}
|
||||
|
||||
gridView.setOptions({
|
||||
summaryMode: "aggregate"
|
||||
});
|
||||
|
||||
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||
return layout.some(item => {
|
||||
if (typeof item === 'string') return false;
|
||||
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||
return checkFooterUserSpans(item.items);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
if (checkFooterUserSpans(columnLayout)) {
|
||||
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||
setTimeout(() => {
|
||||
if (gridRef.current?.gridView && columnLayout) {
|
||||
gridRef.current.gridView.setColumnLayout?.(columnLayout);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||
// setFooters 호출 후 footer 배열 설정
|
||||
setTimeout(() => {
|
||||
const currentGridView = gridRef.current?.gridView;
|
||||
if (!currentGridView || !currentGridView.columnByName) {
|
||||
console.warn('GridView not available for footer setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||
if (hasArrayFooter) {
|
||||
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||
const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => {
|
||||
const colInfo: RealGridColumnInfo = {
|
||||
name: col.name,
|
||||
fieldName: col.fieldName,
|
||||
header: col.header,
|
||||
editable: col.editable,
|
||||
valueType: col.valueType,
|
||||
dataFormat: col.dataFormat,
|
||||
...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }),
|
||||
};
|
||||
|
||||
if (col.footer) {
|
||||
if (Array.isArray(col.footer)) {
|
||||
colInfo.footer = col.footer;
|
||||
} else {
|
||||
colInfo.footer = {
|
||||
text: col.footer.text,
|
||||
expression: col.footer.expression,
|
||||
numberFormat: col.footer.numberFormat,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return colInfo;
|
||||
});
|
||||
|
||||
try {
|
||||
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||
|
||||
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||
setTimeout(() => {
|
||||
columns.forEach(col => {
|
||||
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||
|
||||
try {
|
||||
const column = currentGridView.columnByName(col.name);
|
||||
if (!column) return;
|
||||
|
||||
// footer 배열의 각 요소에 valueCallback 설정
|
||||
col.footer.forEach((footerItem, index) => {
|
||||
if (footerItem.valueCallback && column.footer) {
|
||||
// footer가 배열인 경우
|
||||
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set columns with footer array:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||
columns.forEach(col => {
|
||||
if (!col.footer || Array.isArray(col.footer)) return;
|
||||
|
||||
try {
|
||||
const column = currentGridView.columnByName(col.name);
|
||||
if (!column || !column.footer) return;
|
||||
|
||||
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] w-1/2">
|
||||
<div className="h-[65vh]">
|
||||
<RealGridReact
|
||||
ref={gridRef}
|
||||
autoGenerateField={true}
|
||||
layout={columnLayout}
|
||||
rows={rowData}
|
||||
onInitialized={(grid) => {
|
||||
const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined;
|
||||
if (dp?.setFields) {
|
||||
dp.setFields(buildFieldsFromColumns(columns));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<RGDataColumn
|
||||
key={column.name}
|
||||
name={column.name}
|
||||
fieldName={column.fieldName}
|
||||
valueType={column.valueType ? column.valueType : ValueType.TEXT}
|
||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
||||
header={column.header}
|
||||
editable={column.editable}
|
||||
{...(column.visible === false && { visible: false })}
|
||||
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||
text: column.footer.text,
|
||||
expression: column.footer.expression,
|
||||
numberFormat: column.footer.numberFormat,
|
||||
} : undefined}
|
||||
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
||||
sortable={!column.sortdisable}
|
||||
/>
|
||||
))}
|
||||
</RealGridReact>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactDataGrid;
|
||||
@@ -0,0 +1,725 @@
|
||||
import { useRef, useEffect } from "react"
|
||||
import { ValueType } from "realgrid"
|
||||
|
||||
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"
|
||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
||||
import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type"
|
||||
import { getComputedValueCallback } from "../type/computedFieldCallbacks"
|
||||
import JSZip from "jszip"
|
||||
import * as XLSX from "xlsx"
|
||||
|
||||
import { useAlertStore } from "../store/alert-store";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
import { useTabStore } from "../store/tab-store";
|
||||
|
||||
// RealGrid 타입 확장
|
||||
declare module "realgrid" {
|
||||
interface GridView {
|
||||
setOptions(options: {
|
||||
footer?: { visible?: boolean };
|
||||
summaryMode?: 'aggregate' | 'statistical' | 'none';
|
||||
checkBar?: { visible?: boolean };
|
||||
}): void;
|
||||
}
|
||||
}
|
||||
|
||||
// Window 인터페이스 확장
|
||||
declare global {
|
||||
interface Window {
|
||||
JSZip: typeof JSZip;
|
||||
}
|
||||
}
|
||||
|
||||
// RealGrid가 jszip을 사용할 수 있도록 전역에 등록
|
||||
if (typeof window !== "undefined") {
|
||||
window.JSZip = JSZip;
|
||||
}
|
||||
|
||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||
|
||||
/** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */
|
||||
interface RealGridDataProviderComparer {
|
||||
setDataComparer?(field: string | number, comparer: (field: string | number, row1: number, row2: number) => number): void;
|
||||
getValue?(row: number, field: string | number): unknown;
|
||||
}
|
||||
|
||||
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
|
||||
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
|
||||
return (field, row1, row2) => {
|
||||
const get = dp.getValue?.bind(dp);
|
||||
if (!get) return 0;
|
||||
const val1 = get(row1, field);
|
||||
const val2 = get(row2, field);
|
||||
if (val1 == null && val2 == null) return 0;
|
||||
if (val1 == null) return 1;
|
||||
if (val2 == null) return -1;
|
||||
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||
if (isNaN(num1)) return 1;
|
||||
if (isNaN(num2)) return -1;
|
||||
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
|
||||
function toNum(v: unknown): number {
|
||||
if (v == null) return NaN;
|
||||
if (typeof v === "number") return isNaN(v) ? NaN : v;
|
||||
return parseFloat(String(v).replace(/,/g, "")) || NaN;
|
||||
}
|
||||
|
||||
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
|
||||
function updateHeaderSortIndicator(
|
||||
gridView: {
|
||||
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
|
||||
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||
},
|
||||
columns: { fieldName?: string; name: string }[],
|
||||
sortDir: Record<string, "asc" | "desc" | "none">
|
||||
) {
|
||||
try {
|
||||
columns.forEach((col) => {
|
||||
if (!col.fieldName && !col.name) return;
|
||||
|
||||
// 컬럼 찾기
|
||||
let gridCol: { fieldName?: string; name?: string } | undefined;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const c = gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i);
|
||||
if (!c) break;
|
||||
if (c.fieldName === (col.fieldName || col.name) || c.name === (col.fieldName || col.name)) {
|
||||
gridCol = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!gridCol) {
|
||||
gridCol = gridView.columnByName?.(col.name);
|
||||
}
|
||||
|
||||
if (!gridCol) return;
|
||||
|
||||
const fieldName = gridCol.fieldName || gridCol.name || col.fieldName || col.name;
|
||||
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
|
||||
|
||||
// DOM에서 헤더 요소 찾기
|
||||
const gridEl1 = document.querySelector('[data-grid-id]') as HTMLElement | null;
|
||||
const gridEl2 = document.querySelector('.rg-grid') as HTMLElement | null;
|
||||
const gridEl = gridEl1 || gridEl2;
|
||||
if (!gridEl) return;
|
||||
|
||||
// 헤더 셀 찾기 (fieldName 또는 name으로)
|
||||
const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"]');
|
||||
let targetCell: HTMLElement | null = null;
|
||||
|
||||
headerCells.forEach((cell) => {
|
||||
const el = cell as HTMLElement;
|
||||
const text = el.textContent?.trim();
|
||||
const colName = col.name;
|
||||
const colFieldName = col.fieldName;
|
||||
if (text && (text.includes(colName) || (colFieldName && text.includes(colFieldName)))) {
|
||||
targetCell = el;
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetCell) return;
|
||||
|
||||
const headerCell = targetCell as HTMLElement;
|
||||
|
||||
// 기존 화살표 제거
|
||||
const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null;
|
||||
if (existingIndicator) {
|
||||
existingIndicator.remove();
|
||||
}
|
||||
|
||||
// 화살표 추가
|
||||
if (direction === "asc" || direction === "desc") {
|
||||
const indicator = document.createElement("span");
|
||||
indicator.className = "rg-sort-indicator";
|
||||
indicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||
indicator.style.marginLeft = "4px";
|
||||
indicator.style.fontSize = "10px";
|
||||
indicator.style.color = "#666";
|
||||
indicator.style.display = "inline-block";
|
||||
indicator.style.verticalAlign = "middle";
|
||||
headerCell.appendChild(indicator);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("updateHeaderSortIndicator error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
|
||||
function createOnSortingHandler(
|
||||
columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[],
|
||||
setRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>,
|
||||
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
|
||||
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>
|
||||
) {
|
||||
const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))];
|
||||
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
|
||||
// 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
|
||||
const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName);
|
||||
const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0];
|
||||
const groupField = mergeCellCol?.fieldName || null;
|
||||
const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음
|
||||
const hasMergeCells = mergeCellCols.length > 0;
|
||||
|
||||
return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) {
|
||||
const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } };
|
||||
const idx = fields?.[0];
|
||||
if (idx === undefined) return true;
|
||||
let sortField: string | null = null;
|
||||
if (typeof idx === "string") sortField = idx;
|
||||
else {
|
||||
const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0);
|
||||
sortField = (colObj?.fieldName ?? colObj?.name) ?? null;
|
||||
}
|
||||
if (!sortField) return true;
|
||||
const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField));
|
||||
if (!col || col.sortdisable) return true;
|
||||
|
||||
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
|
||||
if (!hasMergeCells || !groupField) return true;
|
||||
|
||||
const ds = grid.getDataSource?.();
|
||||
if (!ds) return true;
|
||||
const cnt = ds.getRowCount();
|
||||
const rows: Record<string, string | number>[] = [];
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const r: Record<string, string | number> = {};
|
||||
for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
|
||||
rows.push(r);
|
||||
}
|
||||
const prev = sortDirRef.current[sortField] ?? "none";
|
||||
const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds);
|
||||
|
||||
if (prev === "desc") {
|
||||
const orig = originalRowsRef.current[sortField];
|
||||
if (orig && setRows) {
|
||||
setRows(orig);
|
||||
setRowData(orig);
|
||||
}
|
||||
sortDirRef.current[sortField] = "none";
|
||||
return false;
|
||||
}
|
||||
if (prev === "none") {
|
||||
originalRowsRef.current[sortField] = rows.map((r) => ({ ...r }));
|
||||
}
|
||||
const sortAsc = prev !== "asc";
|
||||
sortDirRef.current[sortField] = sortAsc ? "asc" : "desc";
|
||||
const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType);
|
||||
setRows?.(sorted);
|
||||
setRowData(sorted);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
|
||||
function sortRowsGroupPreserving(
|
||||
rows: Record<string, string | number>[],
|
||||
sortField: string,
|
||||
sortAsc: boolean,
|
||||
opt: { groupField: string; orderByField?: string },
|
||||
valueType?: ValueType
|
||||
): Record<string, string | number>[] {
|
||||
const groups = new Map<string, Record<string, string | number>[]>();
|
||||
for (const r of rows) {
|
||||
const k = String(r[opt.groupField] ?? "");
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k)!.push(r);
|
||||
}
|
||||
|
||||
// valueType에 따라 비교 함수 선택
|
||||
const compareValue = (val: unknown): number | string => {
|
||||
if (valueType === ValueType.NUMBER) {
|
||||
return toNum(val);
|
||||
}
|
||||
// 텍스트나 기타 타입은 문자열로 비교
|
||||
return String(val ?? "");
|
||||
};
|
||||
|
||||
const rep = (arr: Record<string, string | number>[]): number | string => {
|
||||
const values = arr.map((r) => compareValue(r[sortField]));
|
||||
if (valueType === ValueType.NUMBER) {
|
||||
const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||
if (nums.length === 0) return sortAsc ? Infinity : -Infinity;
|
||||
return sortAsc ? Math.min(...nums) : Math.max(...nums);
|
||||
} else {
|
||||
// 문자열 정렬
|
||||
const strs = values.filter((v): v is string => typeof v === "string");
|
||||
if (strs.length === 0) return sortAsc ? "\uffff" : "";
|
||||
strs.sort();
|
||||
return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string);
|
||||
}
|
||||
};
|
||||
|
||||
const entries = [...groups.entries()].sort((a, b) => {
|
||||
const valA = rep(a[1]);
|
||||
const valB = rep(b[1]);
|
||||
if (typeof valA === "number" && typeof valB === "number") {
|
||||
return (sortAsc ? 1 : -1) * (valA - valB);
|
||||
}
|
||||
// 문자열 비교
|
||||
const strA = String(valA);
|
||||
const strB = String(valB);
|
||||
return (sortAsc ? 1 : -1) * strA.localeCompare(strB);
|
||||
});
|
||||
|
||||
return entries.flatMap(([, arr]) => arr);
|
||||
}
|
||||
|
||||
/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */
|
||||
function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) {
|
||||
return columns.map((col) => {
|
||||
const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = {
|
||||
fieldName: col.fieldName,
|
||||
dataType: col.valueType === ValueType.NUMBER ? "number" : "text",
|
||||
};
|
||||
if (col.computed) {
|
||||
const hasFields = "fields" in col.computed && col.computed.fields.length > 0;
|
||||
const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0;
|
||||
if (hasFields || hasNumeratorDenominator) {
|
||||
field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number;
|
||||
}
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}
|
||||
|
||||
const DataGrid = ({ columnInfo, rowStartNum, isExcelImport = true, isExcelExport = true, fixedColumns, showHeaderCheckbox = false, rowData, setRowData }: DataGridProps) => {
|
||||
const { 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);
|
||||
|
||||
const exportExcel = () => {
|
||||
if (rowData.length === 0) {
|
||||
alert('데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gridRef.current?.gridView) return;
|
||||
|
||||
const gridView = gridRef.current.gridView;
|
||||
|
||||
const menuName = menuList
|
||||
?.flatMap((m) => m.children || [])
|
||||
.flatMap((subM) => subM || [])
|
||||
.find((s) => s.menuEngNm === activeTabId)
|
||||
?.menuKornNm || 'export';
|
||||
|
||||
gridView.exportGrid({
|
||||
type: 'excel',
|
||||
target: 'local',
|
||||
fileName: `${menuName}.xlsx`,
|
||||
footer: 'hidden',
|
||||
done: () => {
|
||||
alert('내보내기가 완료되었습니다.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const importExcel = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = event.target?.result;
|
||||
if (!data) {
|
||||
alert('파일을 읽을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// SheetJS로 엑셀 파일을 JSON으로 변환
|
||||
const workbook = XLSX.read(data, { type: 'array', cellDates: true });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName || ''];
|
||||
|
||||
if (!worksheet || !worksheet['!ref']) {
|
||||
alert('워크시트를 읽을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref']);
|
||||
const headerRowCount = rowStartNum || 1; // 기본값 1
|
||||
const headerStartRow = Math.max(0, headerRowCount - 1); // 0-based index
|
||||
|
||||
// 각 컬럼별로 헤더 찾기 (rowStartNum 행부터 위로 올라가면서)
|
||||
const headers: string[] = [];
|
||||
const headerRowIndexes: number[] = [];
|
||||
|
||||
for (let col = range.s.c; col <= range.e.c; col++) {
|
||||
let headerValue = '';
|
||||
let headerRowIndex = headerStartRow;
|
||||
|
||||
// rowStartNum 행부터 시작해서 위로 올라가면서 헤더 찾기
|
||||
for (let rowIndex = headerStartRow; rowIndex >= 0; rowIndex--) {
|
||||
const cell = worksheet[XLSX.utils.encode_cell({ r: rowIndex, c: col })];
|
||||
if (cell && cell.v !== undefined && cell.v !== null && cell.v !== '') {
|
||||
headerValue = String(cell.v);
|
||||
headerRowIndex = rowIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더를 찾지 못한 경우 기본값 사용
|
||||
if (!headerValue) {
|
||||
headerValue = `Column${col + 1}`;
|
||||
headerRowIndex = headerStartRow;
|
||||
}
|
||||
|
||||
headers.push(headerValue);
|
||||
headerRowIndexes.push(headerRowIndex);
|
||||
}
|
||||
|
||||
// 실제 데이터가 시작하는 행 찾기 (헤더가 있는 최대 행 번호 + 1)
|
||||
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
||||
const dataStartRow = maxHeaderRowIndex + 1;
|
||||
|
||||
// 헤더 텍스트를 fieldName으로 매핑하는 맵 생성
|
||||
const headerToFieldNameMap = new Map<string, string>();
|
||||
headers.forEach((headerText) => {
|
||||
// columns에서 header.text가 일치하는 컬럼 찾기
|
||||
const matchedColumn = columns.find(
|
||||
(col) => col.header?.text === headerText || col.fieldName === headerText
|
||||
);
|
||||
if (matchedColumn?.fieldName) {
|
||||
headerToFieldNameMap.set(headerText, matchedColumn.fieldName);
|
||||
} else {
|
||||
// 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성)
|
||||
headerToFieldNameMap.set(headerText, headerText);
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터 행 읽기
|
||||
const dataRows: Record<string, string | number>[] = [];
|
||||
for (let row = dataStartRow; row <= range.e.r; row++) {
|
||||
const rowData: Record<string, string | number> = {};
|
||||
let hasData = false;
|
||||
|
||||
for (let colIndex = 0; colIndex < headers.length; colIndex++) {
|
||||
const col = range.s.c + colIndex;
|
||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
||||
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
||||
// 헤더 텍스트를 fieldName으로 변환
|
||||
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
|
||||
|
||||
let cellValue: string | number = '';
|
||||
if (cell && cell.v !== undefined && cell.v !== null) {
|
||||
// 엑셀 셀 타입이 숫자이거나 값이 숫자인 경우 숫자로 변환
|
||||
if (cell.t === 'n' || (typeof cell.v === 'number' && !isNaN(cell.v))) {
|
||||
cellValue = cell.v as number;
|
||||
} else {
|
||||
const stringValue = String(cell.v);
|
||||
// 문자열이지만 숫자로 변환 가능한 경우 숫자로 변환
|
||||
const numericValue = Number(stringValue.replace(/,/g, ''));
|
||||
if (!isNaN(numericValue) && stringValue.trim() !== '') {
|
||||
cellValue = numericValue;
|
||||
} else {
|
||||
cellValue = stringValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cellValue !== '' && cellValue !== null && cellValue !== undefined) {
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
rowData[fieldName] = cellValue;
|
||||
}
|
||||
|
||||
// 데이터가 하나라도 있는 행만 추가
|
||||
if (hasData) {
|
||||
dataRows.push(rowData);
|
||||
}
|
||||
}
|
||||
setRowData(dataRows);
|
||||
} catch (error) {
|
||||
console.error('엑셀 파일 파싱 오류:', error);
|
||||
alert('엑셀 파일 파싱 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
alert('파일 읽기 중 오류가 발생했습니다.');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (error) {
|
||||
console.error('엑셀 파일 가져오기 오류:', error);
|
||||
alert('엑셀 파일 가져오기 중 오류가 발생했습니다.');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current?.gridView || !gridRef.current?.dataProvider) return;
|
||||
|
||||
const gridView = gridRef.current.gridView;
|
||||
const dataProvider = gridRef.current.dataProvider;
|
||||
|
||||
// mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리
|
||||
const hasMergeCells = columns.some((col) => col.mergeCells === true);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 고정 컬럼 설정
|
||||
if (fixedColumns !== undefined && fixedColumns > 0) {
|
||||
gridView.setFixedOptions({
|
||||
colCount: fixedColumns,
|
||||
});
|
||||
}
|
||||
|
||||
gridView.setOptions({
|
||||
summaryMode: "aggregate"
|
||||
});
|
||||
|
||||
// footerUserSpans가 있는 경우 setColumnLayout으로 설정
|
||||
const checkFooterUserSpans = (layout: unknown[]): boolean => {
|
||||
return layout.some(item => {
|
||||
if (typeof item === 'string') return false;
|
||||
if (item && typeof item === 'object' && 'footerUserSpans' in item) return true;
|
||||
if (item && typeof item === 'object' && 'items' in item && Array.isArray(item.items)) {
|
||||
return checkFooterUserSpans(item.items);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
if (checkFooterUserSpans(columnLayout)) {
|
||||
// setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함
|
||||
setTimeout(() => {
|
||||
if (gridRef.current?.gridView && columnLayout) {
|
||||
gridRef.current.gridView.setColumnLayout?.(columnLayout);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// footer 설정 (배열 또는 단일 객체 모두 처리)
|
||||
// setFooters 호출 후 footer 배열 설정
|
||||
setTimeout(() => {
|
||||
const currentGridView = gridRef.current?.gridView;
|
||||
if (!currentGridView || !currentGridView.columnByName) {
|
||||
console.warn('GridView not available for footer setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// footer가 배열인 컬럼이 있으면 setColumns를 다시 호출하여 footer 설정
|
||||
const hasArrayFooter = columns.some(col => col.footer && Array.isArray(col.footer));
|
||||
if (hasArrayFooter) {
|
||||
// 모든 컬럼 정보를 다시 가져와서 footer 배열을 포함하여 setColumns 호출
|
||||
const columnsWithFooter: RealGridColumnInfo[] = columns.map(col => {
|
||||
const colInfo: RealGridColumnInfo = {
|
||||
name: col.name,
|
||||
fieldName: col.fieldName,
|
||||
header: col.header,
|
||||
editable: col.editable,
|
||||
valueType: col.valueType,
|
||||
dataFormat: col.dataFormat,
|
||||
...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }),
|
||||
};
|
||||
|
||||
if (col.footer) {
|
||||
if (Array.isArray(col.footer)) {
|
||||
colInfo.footer = col.footer;
|
||||
} else {
|
||||
colInfo.footer = {
|
||||
text: col.footer.text,
|
||||
expression: col.footer.expression,
|
||||
numberFormat: col.footer.numberFormat,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return colInfo;
|
||||
});
|
||||
|
||||
try {
|
||||
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||
|
||||
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||
setTimeout(() => {
|
||||
columns.forEach(col => {
|
||||
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||
|
||||
try {
|
||||
const column = currentGridView.columnByName(col.name);
|
||||
if (!column) return;
|
||||
|
||||
// footer 배열의 각 요소에 valueCallback 설정
|
||||
col.footer.forEach((footerItem, index) => {
|
||||
if (footerItem.valueCallback && column.footer) {
|
||||
// footer가 배열인 경우
|
||||
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set columns with footer array:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||
columns.forEach(col => {
|
||||
if (!col.footer || Array.isArray(col.footer)) return;
|
||||
|
||||
try {
|
||||
const column = currentGridView.columnByName(col.name);
|
||||
if (!column || !column.footer) return;
|
||||
|
||||
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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex justify-end items-center gap-[0.6rem] px-[1.2rem]">
|
||||
{isExcelImport &&
|
||||
<Button className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover w-[96px]" type="button" onClick={importExcel}>
|
||||
<i className="diveicon di-file-excel" />
|
||||
<span>가져오기</span>
|
||||
</Button>
|
||||
}
|
||||
{isExcelExport &&
|
||||
<Button className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover w-[96px]" type="button" onClick={exportExcel}>
|
||||
<i className="diveicon di-file-excel" />
|
||||
<span>내보내기</span>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
<div className="h-[65vh]">
|
||||
<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);
|
||||
const dp = gridRef.current?.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined;
|
||||
if (dp?.setFields) {
|
||||
dp.setFields(buildFieldsFromColumns(columns));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<RGDataColumn
|
||||
key={column.name}
|
||||
name={column.name}
|
||||
fieldName={column.fieldName}
|
||||
valueType={column.valueType ? column.valueType : ValueType.TEXT}
|
||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })}
|
||||
header={column.header}
|
||||
editable={column.editable}
|
||||
{...(column.visible === false && { visible: false })}
|
||||
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||
text: column.footer.text,
|
||||
expression: column.footer.expression,
|
||||
numberFormat: column.footer.numberFormat,
|
||||
} : undefined}
|
||||
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
||||
sortable={!column.sortdisable}
|
||||
/>
|
||||
))}
|
||||
</RealGridReact>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGrid;
|
||||
@@ -0,0 +1,35 @@
|
||||
import CompactDataGrid from "./CompactDataGrid";
|
||||
import type { SplitDataGridProps } from "../type/DataGrid.type";
|
||||
|
||||
const SplitDataGrid = ({
|
||||
leftColumnInfo,
|
||||
rightColumnInfo,
|
||||
leftFixedColumns,
|
||||
rightFixedColumns,
|
||||
showHeaderCheckbox = false,
|
||||
leftRowData,
|
||||
rightRowData,
|
||||
setLeftRowData,
|
||||
setRightRowData,
|
||||
}: SplitDataGridProps) => {
|
||||
return (
|
||||
<div className="flex gap-[0.6rem] mx-[0.6rem]">
|
||||
<CompactDataGrid
|
||||
columnInfo={leftColumnInfo}
|
||||
fixedColumns={leftFixedColumns}
|
||||
showHeaderCheckbox={showHeaderCheckbox}
|
||||
rowData={leftRowData}
|
||||
setRowData={setLeftRowData}
|
||||
/>
|
||||
<CompactDataGrid
|
||||
columnInfo={rightColumnInfo}
|
||||
fixedColumns={rightFixedColumns}
|
||||
showHeaderCheckbox={showHeaderCheckbox}
|
||||
rowData={rightRowData}
|
||||
setRowData={setRightRowData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitDataGrid;
|
||||
@@ -0,0 +1,964 @@
|
||||
import { useRef, useEffect, useState } from "react"
|
||||
import { ValueType } from "realgrid"
|
||||
|
||||
import RealGrid, { GridFitStyle } from "realgrid"
|
||||
import { RealGridReact, RGDataColumn } from "realgrid-react"
|
||||
import type { TabDataGridProps, ColumnInfo, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback } from "../type/DataGrid.type"
|
||||
import { getComputedValueCallback } from "@/type/computedFieldCallbacks"
|
||||
import JSZip from "jszip"
|
||||
import * as XLSX from "xlsx"
|
||||
import { useAlertStore } from "../store/alert-store";
|
||||
import { useTabStore } from "../store/tab-store";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components";
|
||||
import { Button } from "@common/components";
|
||||
import SplitDataGrid from "./SplitDataGrid"
|
||||
|
||||
// RealGrid 타입 확장
|
||||
declare module "realgrid" {
|
||||
interface GridView {
|
||||
setOptions(options: {
|
||||
footer?: { visible?: boolean };
|
||||
summaryMode?: 'aggregate' | 'statistical' | 'none';
|
||||
checkBar?: { visible?: boolean };
|
||||
}): void;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
JSZip: typeof JSZip;
|
||||
}
|
||||
}
|
||||
|
||||
// RealGrid가 jszip을 사용할 수 있도록 전역에 등록
|
||||
if (typeof window !== "undefined") {
|
||||
window.JSZip = JSZip;
|
||||
}
|
||||
|
||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||
|
||||
/** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */
|
||||
function toNum(v: unknown): number {
|
||||
if (v == null) return NaN;
|
||||
if (typeof v === "number") return isNaN(v) ? NaN : v;
|
||||
return parseFloat(String(v).replace(/,/g, "")) || NaN;
|
||||
}
|
||||
|
||||
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
|
||||
function updateHeaderSortIndicator(
|
||||
gridView: {
|
||||
columnByIndex?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||
columnByName?: (name: string) => { fieldName?: string; name?: string } | undefined;
|
||||
getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined;
|
||||
getElement?: () => HTMLElement | null;
|
||||
container?: HTMLElement;
|
||||
},
|
||||
columns: { fieldName?: string; name: string }[],
|
||||
sortDir: Record<string, "asc" | "desc" | "none">,
|
||||
gridElement?: HTMLElement | null
|
||||
) {
|
||||
try {
|
||||
// 그리드 DOM 요소 찾기
|
||||
let gridEl: HTMLElement | null = null;
|
||||
if (gridElement) {
|
||||
gridEl = gridElement;
|
||||
} else if (gridView.getElement) {
|
||||
gridEl = gridView.getElement();
|
||||
} else if (gridView.container) {
|
||||
gridEl = gridView.container;
|
||||
} else {
|
||||
// 폴백: 모든 그리드 중에서 찾기
|
||||
const allGrids = document.querySelectorAll('.rg-grid, [data-grid-id]');
|
||||
if (allGrids.length > 0) {
|
||||
// 활성 탭의 그리드를 찾기 위해 TabsContent 내부의 그리드 찾기
|
||||
const activeTabContent = document.querySelector('[data-state="active"]')?.closest('[role="tabpanel"]');
|
||||
if (activeTabContent) {
|
||||
gridEl = activeTabContent.querySelector('.rg-grid, [data-grid-id]') as HTMLElement;
|
||||
}
|
||||
if (!gridEl && allGrids.length === 1) {
|
||||
gridEl = allGrids[0] as HTMLElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gridEl) return;
|
||||
|
||||
columns.forEach((col) => {
|
||||
if (!col.fieldName && !col.name) return;
|
||||
|
||||
const fieldName = col.fieldName || col.name;
|
||||
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
|
||||
if (direction === "none") return;
|
||||
|
||||
// 헤더 셀 찾기 (fieldName 또는 name으로)
|
||||
const headerCells = gridEl.querySelectorAll('.rg-header-cell, [class*="header"], .rg-header');
|
||||
let targetCell: HTMLElement | null = null;
|
||||
|
||||
headerCells.forEach((cell) => {
|
||||
const el = cell as HTMLElement;
|
||||
const text = el.textContent?.trim();
|
||||
const colName = col.name;
|
||||
const colFieldName = col.fieldName;
|
||||
// 헤더 텍스트와 컬럼 이름이 일치하는지 확인
|
||||
if (text && (
|
||||
text === colName ||
|
||||
text === colFieldName ||
|
||||
text.includes(colName) ||
|
||||
(colFieldName && text.includes(colFieldName))
|
||||
)) {
|
||||
targetCell = el;
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetCell) return;
|
||||
|
||||
const headerCell = targetCell as HTMLElement;
|
||||
|
||||
// 기존 화살표 제거
|
||||
const existingIndicator = headerCell.querySelector(".rg-sort-indicator") as HTMLElement | null;
|
||||
if (existingIndicator) {
|
||||
existingIndicator.remove();
|
||||
}
|
||||
|
||||
// 화살표 추가
|
||||
const indicator = document.createElement("span");
|
||||
indicator.className = "rg-sort-indicator";
|
||||
indicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||
indicator.style.marginLeft = "4px";
|
||||
indicator.style.fontSize = "10px";
|
||||
indicator.style.color = "#666";
|
||||
indicator.style.display = "inline-block";
|
||||
indicator.style.verticalAlign = "middle";
|
||||
headerCell.appendChild(indicator);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("updateHeaderSortIndicator error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
|
||||
function createOnSortingHandler(
|
||||
columns: { fieldName?: string; name: string; mergeCells?: boolean; sortdisable?: boolean; valueType?: ValueType }[],
|
||||
setRowData: (updater: React.SetStateAction<Record<string, string | number>[]>) => void,
|
||||
sortDirRef: React.MutableRefObject<Record<string, "asc" | "desc" | "none">>,
|
||||
originalRowsRef: React.MutableRefObject<Record<string, Record<string, string | number>[]>>,
|
||||
findGridElement?: () => HTMLElement | null
|
||||
) {
|
||||
const fieldNames = [...new Set(columns.map((c) => c.fieldName).filter((x): x is string => typeof x === "string"))];
|
||||
// mergeCells: true인 컬럼 찾기 (시군구 등) - 그룹 필드로 사용
|
||||
// 시군구를 우선으로 찾고, 없으면 첫 번째 mergeCells 컬럼 사용
|
||||
const mergeCellCols = columns.filter((c) => c.mergeCells === true && c.fieldName);
|
||||
const mergeCellCol = mergeCellCols.find((c) => c.fieldName === '시군구') || mergeCellCols[0];
|
||||
const groupField = mergeCellCol?.fieldName || null;
|
||||
const orderByField = '구분'; // 고정값 또는 설정 가능하게 할 수 있음
|
||||
const hasMergeCells = mergeCellCols.length > 0;
|
||||
|
||||
return function onSorting(g: unknown, fields: unknown[], _directions: unknown[]) {
|
||||
const grid = g as { getColumn?: (i: number) => { fieldName?: string; name?: string } | undefined; getDataSource?: () => { getRowCount(): number; getValue(r: number, f: string): unknown } };
|
||||
const idx = fields?.[0];
|
||||
if (idx === undefined) return true;
|
||||
let sortField: string | null = null;
|
||||
if (typeof idx === "string") sortField = idx;
|
||||
else {
|
||||
const colObj = grid?.getColumn?.(typeof idx === "number" ? idx : 0);
|
||||
sortField = (colObj?.fieldName ?? colObj?.name) ?? null;
|
||||
}
|
||||
if (!sortField) return true;
|
||||
const col = columns.find((c) => (c.fieldName === sortField || c.name === sortField));
|
||||
if (!col || col.sortdisable) return true;
|
||||
|
||||
// mergeCells: true인 컬럼이 없으면 RealGrid 기본 정렬 사용
|
||||
if (!hasMergeCells || !groupField) return true;
|
||||
|
||||
const ds = grid.getDataSource?.();
|
||||
if (!ds) return true;
|
||||
const cnt = ds.getRowCount();
|
||||
const rows: Record<string, string | number>[] = [];
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const r: Record<string, string | number> = {};
|
||||
for (const f of fieldNames) r[f] = ds.getValue(i, f) as string | number;
|
||||
rows.push(r);
|
||||
}
|
||||
const prev = sortDirRef.current[sortField] ?? "none";
|
||||
const setRows = (ds as { setRows?: (r: unknown[]) => void }).setRows?.bind(ds);
|
||||
|
||||
if (prev === "desc") {
|
||||
const orig = originalRowsRef.current[sortField];
|
||||
if (orig && setRows) {
|
||||
setRows(orig);
|
||||
setRowData(orig);
|
||||
}
|
||||
sortDirRef.current[sortField] = "none";
|
||||
return false;
|
||||
}
|
||||
if (prev === "none") {
|
||||
originalRowsRef.current[sortField] = rows.map((r) => ({ ...r }));
|
||||
}
|
||||
const sortAsc = prev !== "asc";
|
||||
sortDirRef.current[sortField] = sortAsc ? "asc" : "desc";
|
||||
const sorted = sortRowsGroupPreserving(rows, sortField, sortAsc, { groupField, orderByField }, col.valueType);
|
||||
setRows?.(sorted);
|
||||
setRowData(sorted);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/** sortPreserveGroupOrder: 그룹 간에는 sortField로 정렬, 그룹 내에서는 원본 순서 유지. */
|
||||
function sortRowsGroupPreserving(
|
||||
rows: Record<string, string | number>[],
|
||||
sortField: string,
|
||||
sortAsc: boolean,
|
||||
opt: { groupField: string; orderByField?: string },
|
||||
valueType?: ValueType
|
||||
): Record<string, string | number>[] {
|
||||
const groups = new Map<string, Record<string, string | number>[]>();
|
||||
for (const r of rows) {
|
||||
const k = String(r[opt.groupField] ?? "");
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k)!.push(r);
|
||||
}
|
||||
|
||||
// valueType에 따라 비교 함수 선택
|
||||
const compareValue = (val: unknown): number | string => {
|
||||
if (valueType === ValueType.NUMBER) {
|
||||
return toNum(val);
|
||||
}
|
||||
// 텍스트나 기타 타입은 문자열로 비교
|
||||
return String(val ?? "");
|
||||
};
|
||||
|
||||
const rep = (arr: Record<string, string | number>[]): number | string => {
|
||||
const values = arr.map((r) => compareValue(r[sortField]));
|
||||
if (valueType === ValueType.NUMBER) {
|
||||
const nums = values.filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||
if (nums.length === 0) return sortAsc ? Infinity : -Infinity;
|
||||
return sortAsc ? Math.min(...nums) : Math.max(...nums);
|
||||
} else {
|
||||
// 문자열 정렬
|
||||
const strs = values.filter((v): v is string => typeof v === "string");
|
||||
if (strs.length === 0) return sortAsc ? "\uffff" : "";
|
||||
strs.sort();
|
||||
return sortAsc ? (strs[0] as string) : (strs[strs.length - 1] as string);
|
||||
}
|
||||
};
|
||||
|
||||
const entries = [...groups.entries()].sort((a, b) => {
|
||||
const valA = rep(a[1]);
|
||||
const valB = rep(b[1]);
|
||||
if (typeof valA === "number" && typeof valB === "number") {
|
||||
return (sortAsc ? 1 : -1) * (valA - valB);
|
||||
}
|
||||
// 문자열 비교
|
||||
const strA = String(valA);
|
||||
const strB = String(valB);
|
||||
return (sortAsc ? 1 : -1) * strA.localeCompare(strB);
|
||||
});
|
||||
|
||||
return entries.flatMap(([, arr]) => arr);
|
||||
}
|
||||
|
||||
/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */
|
||||
function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) {
|
||||
return columns.map((col) => {
|
||||
const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = {
|
||||
fieldName: col.fieldName,
|
||||
dataType: col.valueType === ValueType.NUMBER ? "number" : "text",
|
||||
};
|
||||
if (col.computed) {
|
||||
const hasFields = "fields" in col.computed && col.computed.fields.length > 0;
|
||||
const hasNumeratorDenominator = "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && col.computed.denominatorFields.length > 0;
|
||||
if (hasFields || hasNumeratorDenominator) {
|
||||
field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number;
|
||||
}
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}
|
||||
|
||||
/** setDataComparer용 숫자 비교기. setFields 없이 숫자 정렬. */
|
||||
function createNumberComparer(dp: RealGridDataProviderComparer): (field: string | number, row1: number, row2: number) => number {
|
||||
return (field, row1, row2) => {
|
||||
const get = dp.getValue?.bind(dp);
|
||||
if (!get) return 0;
|
||||
const val1 = get(row1, field);
|
||||
const val2 = get(row2, field);
|
||||
if (val1 == null && val2 == null) return 0;
|
||||
if (val1 == null) return 1;
|
||||
if (val2 == null) return -1;
|
||||
const num1 = typeof val1 === "number" ? val1 : parseFloat(String(val1));
|
||||
const num2 = typeof val2 === "number" ? val2 : parseFloat(String(val2));
|
||||
if (isNaN(num1) && isNaN(num2)) return 0;
|
||||
if (isNaN(num1)) return 1;
|
||||
if (isNaN(num2)) return -1;
|
||||
return num1 > num2 ? 1 : num1 < num2 ? -1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap }: TabDataGridProps) => {
|
||||
// 각 탭별로 그리드 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);
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
// 초기 활성 탭을 첫 번째 tabKey로 설정
|
||||
const firstTab = columnInfo[0];
|
||||
return firstTab?.tabKey || '';
|
||||
});
|
||||
|
||||
const exportExcel = (tabKey: string) => {
|
||||
const currentRowData = rowDataMap.get(tabKey) || [];
|
||||
if (currentRowData.length === 0) {
|
||||
alert('데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const gridRef = gridRefs.current.get(tabKey);
|
||||
if (!gridRef?.gridView) return;
|
||||
|
||||
const gridView = gridRef.gridView;
|
||||
|
||||
const menuName = menuList
|
||||
?.flatMap((m) => m.children || [])
|
||||
.flatMap((subM) => subM || [])
|
||||
.find((s) => s.menuEngNm === activeTabId)
|
||||
?.menuKornNm || 'export';
|
||||
|
||||
const columnInfoItem = columnInfo.find(info => info.tabKey === tabKey);
|
||||
const tabLabel = columnInfoItem?.tabLabel || tabKey;
|
||||
gridView.exportGrid({
|
||||
type: 'excel',
|
||||
target: 'local',
|
||||
fileName: `${menuName}_${tabLabel}.xlsx`,
|
||||
footer: 'hidden',
|
||||
done: () => {
|
||||
alert('내보내기가 완료되었습니다.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const importExcel = (tabKey: string) => {
|
||||
const fileInput = fileInputRefs.current.get(tabKey);
|
||||
fileInput?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>, tabKey: string) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const columnInfoItem = columnInfo.find(info => info.tabKey === tabKey);
|
||||
if (!columnInfoItem) return;
|
||||
|
||||
const rowStartNum = columnInfoItem.rowStartNum || 1;
|
||||
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = event.target?.result;
|
||||
if (!data) {
|
||||
alert('파일을 읽을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// SheetJS로 엑셀 파일을 JSON으로 변환
|
||||
const workbook = XLSX.read(data, { type: 'array', cellDates: true });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName || ''];
|
||||
|
||||
if (!worksheet || !worksheet['!ref']) {
|
||||
alert('워크시트를 읽을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref']);
|
||||
const headerRowCount = rowStartNum || 1; // 기본값 1
|
||||
const headerStartRow = Math.max(0, headerRowCount - 1); // 0-based index
|
||||
|
||||
// 각 컬럼별로 헤더 찾기 (rowStartNum 행부터 위로 올라가면서)
|
||||
const headers: string[] = [];
|
||||
const headerRowIndexes: number[] = [];
|
||||
|
||||
for (let col = range.s.c; col <= range.e.c; col++) {
|
||||
let headerValue = '';
|
||||
let headerRowIndex = headerStartRow;
|
||||
|
||||
// rowStartNum 행부터 시작해서 위로 올라가면서 헤더 찾기
|
||||
for (let rowIndex = headerStartRow; rowIndex >= 0; rowIndex--) {
|
||||
const cell = worksheet[XLSX.utils.encode_cell({ r: rowIndex, c: col })];
|
||||
if (cell && cell.v !== undefined && cell.v !== null && cell.v !== '') {
|
||||
headerValue = String(cell.v);
|
||||
headerRowIndex = rowIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더를 찾지 못한 경우 기본값 사용
|
||||
if (!headerValue) {
|
||||
headerValue = `Column${col + 1}`;
|
||||
headerRowIndex = headerStartRow;
|
||||
}
|
||||
|
||||
headers.push(headerValue);
|
||||
headerRowIndexes.push(headerRowIndex);
|
||||
}
|
||||
|
||||
// 실제 데이터가 시작하는 행 찾기 (헤더가 있는 최대 행 번호 + 1)
|
||||
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
|
||||
const dataStartRow = maxHeaderRowIndex + 1;
|
||||
|
||||
// 헤더 텍스트를 fieldName으로 매핑하는 맵 생성
|
||||
const headerToFieldNameMap = new Map<string, string>();
|
||||
const columns = columnInfoItem.columns || [];
|
||||
headers.forEach((headerText) => {
|
||||
// columns에서 header.text가 일치하는 컬럼 찾기
|
||||
const matchedColumn = columns.find(
|
||||
(col) => col.header?.text === headerText || col.fieldName === headerText
|
||||
);
|
||||
if (matchedColumn?.fieldName) {
|
||||
headerToFieldNameMap.set(headerText, matchedColumn.fieldName);
|
||||
} else {
|
||||
// 매칭되지 않으면 원본 헤더 텍스트 사용 (하위 호환성)
|
||||
headerToFieldNameMap.set(headerText, headerText);
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터 행 읽기
|
||||
const dataRows: Record<string, string | number>[] = [];
|
||||
for (let row = dataStartRow; row <= range.e.r; row++) {
|
||||
const rowData: Record<string, string | number> = {};
|
||||
let hasData = false;
|
||||
|
||||
for (let colIndex = 0; colIndex < headers.length; colIndex++) {
|
||||
const col = range.s.c + colIndex;
|
||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
|
||||
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
|
||||
// 헤더 텍스트를 fieldName으로 변환
|
||||
const fieldName = headerToFieldNameMap.get(headerName) || headerName;
|
||||
|
||||
let cellValue: string | number = '';
|
||||
if (cell && cell.v !== undefined && cell.v !== null) {
|
||||
// 엑셀 셀 타입이 숫자이거나 값이 숫자인 경우 숫자로 변환
|
||||
if (cell.t === 'n' || (typeof cell.v === 'number' && !isNaN(cell.v))) {
|
||||
cellValue = cell.v as number;
|
||||
} else {
|
||||
const stringValue = String(cell.v);
|
||||
// 문자열이지만 숫자로 변환 가능한 경우 숫자로 변환
|
||||
const numericValue = Number(stringValue.replace(/,/g, ''));
|
||||
if (!isNaN(numericValue) && stringValue.trim() !== '') {
|
||||
cellValue = numericValue;
|
||||
} else {
|
||||
cellValue = stringValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cellValue !== '' && cellValue !== null && cellValue !== undefined) {
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
rowData[fieldName] = cellValue;
|
||||
}
|
||||
|
||||
// 데이터가 하나라도 있는 행만 추가
|
||||
if (hasData) {
|
||||
dataRows.push(rowData);
|
||||
}
|
||||
}
|
||||
|
||||
setRowDataMap(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(tabKey, dataRows);
|
||||
return newMap;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('엑셀 파일 파싱 오류:', error);
|
||||
alert('엑셀 파일 파싱 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
const fileInput = fileInputRefs.current.get(tabKey);
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
alert('파일 읽기 중 오류가 발생했습니다.');
|
||||
const fileInput = fileInputRefs.current.get(tabKey);
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (error) {
|
||||
console.error('엑셀 파일 가져오기 오류:', error);
|
||||
alert('엑셀 파일 가져오기 중 오류가 발생했습니다.');
|
||||
const fileInput = fileInputRefs.current.get(tabKey);
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 데이터 설정
|
||||
useEffect(() => {
|
||||
if (rows.length > 0) {
|
||||
columnInfo.forEach((info) => {
|
||||
const tabKey = info.tabKey;
|
||||
if (!rowDataMap.has(tabKey)) {
|
||||
setRowDataMap(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(tabKey, rows);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [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);
|
||||
if (!gridRef?.gridView) return;
|
||||
|
||||
const gridView = gridRef.gridView;
|
||||
// 약간의 지연을 두고 설정 (그리드 렌더링 완료 후)
|
||||
const timer = setTimeout(() => {
|
||||
gridView.setDisplayOptions({
|
||||
fitStyle: GridFitStyle.EVEN
|
||||
});
|
||||
gridView.refresh();
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
if (columnInfo.length === 0) {
|
||||
return <div>컬럼 정보가 없습니다.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" variant="outline">
|
||||
<TabsList className="mb-4">
|
||||
{columnInfo.map((info) => {
|
||||
const tabKey = info.tabKey;
|
||||
const tabLabel = info.tabLabel || tabKey;
|
||||
return (
|
||||
<TabsTrigger key={tabKey} value={tabKey}>
|
||||
{tabLabel}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{columnInfo.map((info) => {
|
||||
const tabKey = info.tabKey;
|
||||
|
||||
// isSplitGrid가 true이고 left, right가 있으면 SplitDataGrid 사용
|
||||
if (info.isSplitGrid && info.left && info.right) {
|
||||
const leftRowData = rowDataMap.get(info.left.tabKey || `${tabKey}_left`) || [];
|
||||
const rightRowData = rowDataMap.get(info.right.tabKey || `${tabKey}_right`) || [];
|
||||
|
||||
// ColumnInfoList를 ColumnInfo로 변환
|
||||
const leftColumnInfo: ColumnInfo = {
|
||||
columns: info.left.columns || [],
|
||||
columnLayout: info.left.columnLayout || []
|
||||
};
|
||||
const rightColumnInfo: ColumnInfo = {
|
||||
columns: info.right.columns || [],
|
||||
columnLayout: info.right.columnLayout || []
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent key={tabKey} value={tabKey}>
|
||||
<SplitDataGrid
|
||||
leftColumnInfo={leftColumnInfo}
|
||||
rightColumnInfo={rightColumnInfo}
|
||||
leftFixedColumns={info.left.fixedColumns}
|
||||
rightFixedColumns={info.right.fixedColumns}
|
||||
showHeaderCheckbox={showHeaderCheckbox}
|
||||
leftRowData={leftRowData}
|
||||
rightRowData={rightRowData}
|
||||
setLeftRowData={(updater) => {
|
||||
setRowDataMap(prev => {
|
||||
const newMap = new Map(prev);
|
||||
const leftTabKey = info.left?.tabKey || `${tabKey}_left`;
|
||||
const currentData = newMap.get(leftTabKey) || [];
|
||||
const newData = typeof updater === 'function' ? updater(currentData) : updater;
|
||||
newMap.set(leftTabKey, newData);
|
||||
return newMap;
|
||||
});
|
||||
}}
|
||||
setRightRowData={(updater) => {
|
||||
setRowDataMap(prev => {
|
||||
const newMap = new Map(prev);
|
||||
const rightTabKey = info.right?.tabKey || `${tabKey}_right`;
|
||||
const currentData = newMap.get(rightTabKey) || [];
|
||||
const newData = typeof updater === 'function' ? updater(currentData) : updater;
|
||||
newMap.set(rightTabKey, newData);
|
||||
return newMap;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 그리드 사용
|
||||
if (!info.columns || !info.columnLayout) {
|
||||
return null;
|
||||
}
|
||||
const { columns, columnLayout } = info;
|
||||
const currentRowData = rowDataMap.get(tabKey) || [];
|
||||
|
||||
return (
|
||||
<TabsContent key={tabKey} value={tabKey}>
|
||||
<div className="flex flex-col gap-[0.6rem]">
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fileInputRefs.current.set(tabKey, el);
|
||||
} else {
|
||||
fileInputRefs.current.delete(tabKey);
|
||||
}
|
||||
}}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => handleFileChange(e, tabKey)}
|
||||
/>
|
||||
<div className="flex justify-end items-center gap-[0.6rem]">
|
||||
{isExcelImport &&
|
||||
<Button
|
||||
className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover w-[96px]"
|
||||
type="button"
|
||||
onClick={() => importExcel(tabKey)}
|
||||
>
|
||||
<i className="diveicon di-file-excel" />
|
||||
<span>가져오기</span>
|
||||
</Button>
|
||||
}
|
||||
{isExcelExport &&
|
||||
<Button
|
||||
className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover w-[96px]"
|
||||
type="button"
|
||||
onClick={() => exportExcel(tabKey)}
|
||||
>
|
||||
<i className="diveicon di-file-excel" />
|
||||
<span>내보내기</span>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
<div className="h-[60vh]" data-tab-key={tabKey}>
|
||||
<RealGridReact
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
gridRefs.current.set(tabKey, el);
|
||||
// 그리드가 생성된 직후 fitStyle 설정
|
||||
if (el.gridView) {
|
||||
setTimeout(() => {
|
||||
const gridView = el.gridView;
|
||||
if (!gridView) return;
|
||||
|
||||
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
|
||||
if (!currentInfo || !currentInfo.columns) return;
|
||||
|
||||
const columns = currentInfo.columns;
|
||||
|
||||
const dp = el.dataProvider as { setFields?: (fields: unknown[]) => void } | undefined;
|
||||
if (dp?.setFields) {
|
||||
dp.setFields(buildFieldsFromColumns(columns));
|
||||
}
|
||||
|
||||
gridView.setDisplayOptions({
|
||||
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) {
|
||||
// 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"
|
||||
});
|
||||
|
||||
// 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: RealGridColumnInfo[] = columns.map(col => {
|
||||
const colInfo: RealGridColumnInfo = {
|
||||
name: col.name,
|
||||
fieldName: col.fieldName,
|
||||
header: col.header,
|
||||
editable: col.editable,
|
||||
valueType: col.valueType,
|
||||
dataFormat: col.dataFormat,
|
||||
...(col.mergeCells && { mergeRule: { criteria: 'value' as const } }),
|
||||
};
|
||||
|
||||
if (col.footer) {
|
||||
if (Array.isArray(col.footer)) {
|
||||
colInfo.footer = col.footer;
|
||||
} else {
|
||||
colInfo.footer = {
|
||||
text: col.footer.text,
|
||||
expression: col.footer.expression,
|
||||
numberFormat: col.footer.numberFormat,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return colInfo;
|
||||
});
|
||||
|
||||
try {
|
||||
(currentGridView as GridViewWithSetColumns).setColumns?.(columnsWithFooter);
|
||||
|
||||
// setColumns 호출 후 footer 배열의 valueCallback을 다시 설정
|
||||
setTimeout(() => {
|
||||
columns.forEach(col => {
|
||||
if (!col.footer || !Array.isArray(col.footer)) return;
|
||||
|
||||
try {
|
||||
const column = currentGridView.columnByName(col.name);
|
||||
if (!column) return;
|
||||
|
||||
// footer 배열의 각 요소에 valueCallback 설정
|
||||
col.footer.forEach((footerItem, index) => {
|
||||
if (footerItem.valueCallback && column.footer) {
|
||||
// footer가 배열인 경우
|
||||
if (Array.isArray(column.footer) && column.footer[index]) {
|
||||
(column.footer[index] as FooterItemWithCallback).valueCallback = footerItem.valueCallback;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Error setting footer array valueCallback for column ${col.name}:`, e);
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set columns with footer array:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// footer가 단일 객체이고 valueCallback이 있는 경우
|
||||
columns.forEach(col => {
|
||||
if (!col.footer || Array.isArray(col.footer)) return;
|
||||
|
||||
try {
|
||||
const column = currentGridView.columnByName(col.name);
|
||||
if (!column || !column.footer) return;
|
||||
|
||||
const footerObj = col.footer as { valueCallback?: (grid: unknown, column: unknown, footerIndex: number, columnFooter: unknown, value: unknown) => unknown };
|
||||
if (footerObj.valueCallback) {
|
||||
(column.footer as { valueCallback?: typeof footerObj.valueCallback }).valueCallback = footerObj.valueCallback;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Error setting footer valueCallback for column ${col.name}:`, e);
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
|
||||
// 고정 컬럼 설정
|
||||
if (currentInfo.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
|
||||
gridView.setFixedOptions({
|
||||
colCount: currentInfo.fixedColumns,
|
||||
});
|
||||
}
|
||||
|
||||
// 각 탭별 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 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
gridRefs.current.delete(tabKey);
|
||||
}
|
||||
}}
|
||||
autoGenerateField={true}
|
||||
layout={columnLayout}
|
||||
rows={currentRowData}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<RGDataColumn
|
||||
key={column.name}
|
||||
name={column.name}
|
||||
fieldName={column.fieldName}
|
||||
valueType={column.valueType ? column.valueType : ValueType.TEXT}
|
||||
{...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.00' })}
|
||||
header={column.header}
|
||||
editable={column.editable}
|
||||
{...(column.visible === false && { visible: false })}
|
||||
footer={column.footer && !Array.isArray(column.footer) ? {
|
||||
text: (column.footer as { text?: string }).text,
|
||||
expression: (column.footer as { expression?: 'sum' | 'avg' | 'count' | 'max' | 'min' }).expression,
|
||||
numberFormat: (column.footer as { numberFormat?: string }).numberFormat,
|
||||
} : undefined}
|
||||
mergeRule={column.mergeCells ? { criteria: 'value' } : undefined}
|
||||
sortable={!column.sortdisable}
|
||||
/>
|
||||
))}
|
||||
</RealGridReact>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabDataGrid;
|
||||
@@ -0,0 +1,113 @@
|
||||
/* RealGrid2 폰트 설정 - 루트 폰트와 일치 */
|
||||
.rg-root,
|
||||
.rg-grid,
|
||||
.rg-body,
|
||||
.rg-cell,
|
||||
.rg-header,
|
||||
.rg-head,
|
||||
.rg-rowbarcontainer {
|
||||
font-family: "Noto Sans KR", sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* RealGrid2 컨테이너 스타일 */
|
||||
.rg-root {
|
||||
border-radius: 0.5rem !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid #dbe2e6 !important;
|
||||
}
|
||||
|
||||
.rg-grid {
|
||||
border-radius: 0.5rem !important;
|
||||
/* box-shadow: 0 0 10px -1px rgba(0, 0, 0, 0.06); */
|
||||
}
|
||||
|
||||
/* 헤더 스타일 */
|
||||
.rg-header,
|
||||
.rg-head,
|
||||
.rg-foot,
|
||||
.rg-footer,
|
||||
.rg-fixed-footer {
|
||||
background-color: #f5f7fa !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* 헤더 셀 스타일 */
|
||||
.rg-header .rg-cell,
|
||||
.rg-head .rg-cell {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
/* 헤더 필터링 영역 위치 조정 - 오른쪽 여백 추가 */
|
||||
.rg-header-filtering {
|
||||
left: auto !important;
|
||||
right: 8px !important;
|
||||
}
|
||||
|
||||
/* 그룹 헤더 스타일 */
|
||||
.rg-header-group,
|
||||
.rg-header-group-cell {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
/* 첫 번째 헤더 셀 - 왼쪽 상단 모서리 */
|
||||
.rg-header .rg-cell:first-child,
|
||||
.rg-head .rg-cell:first-child {
|
||||
border-top-left-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* 마지막 헤더 셀 - 오른쪽 상단 모서리 */
|
||||
.rg-header .rg-cell:last-child,
|
||||
.rg-head .rg-cell:last-child {
|
||||
border-top-right-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* border color 통일 */
|
||||
.rg-header .rg-table tr td,
|
||||
.rg-head .rg-table tr td:not(:first-child),
|
||||
.rg-body .rg-table tr td,
|
||||
.rg-rowbarcontainer .rg-table tr td:not(:first-child) {
|
||||
border-left: 1px solid #dbe2e6 !important;
|
||||
border-bottom: 1px solid #dbe2e6 !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
.rg-rowindicator-head,
|
||||
.rg-rowbarcontainer .rg-table tr td:first-child {
|
||||
border-right: none !important;
|
||||
border-bottom: 1px solid #dbe2e6 !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
.rg-footer .rg-table tr td,
|
||||
.rg-fixed-footer .rg-table tr td,
|
||||
.rg-foot .rg-table tr td {
|
||||
border-right: 1px solid #dbe2e6 !important;
|
||||
border-top: 1px solid #dbe2e6 !important;
|
||||
}
|
||||
|
||||
.rg-rowbarcontainer .rg-table tr td {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
/* 푸터 마지막 요소 - 오른쪽 border 제거 */
|
||||
.rg-footer .rg-table tr td:last-child {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
/* 고정 컬럼 바 */
|
||||
.rg-fixed-column-bar {
|
||||
background-color: #ccc !important;
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* 읽기 전용 cell */
|
||||
.rg-cell-readonly {
|
||||
background-color: #f0f0f0 !important;
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
/* 소계, 합계, 율(%) cell */
|
||||
.rg-cell-total {
|
||||
background-color: #e3eaf2 !important;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "./grid/css/DataGrid.css";
|
||||
|
||||
export { default as DataGrid } from "./grid/DataGrid";
|
||||
export { default as TabDataGrid } from "./grid/TabDataGrid";
|
||||
export * from "./type/DataGrid.type";
|
||||
export * from "./store/alert-store";
|
||||
export * from "./store/tab-store";
|
||||
export { AlertDialog } from "./ui/alert-dialog";
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type AlertState = {
|
||||
message: string;
|
||||
callback?: () => void;
|
||||
};
|
||||
|
||||
export type AlertActions = {
|
||||
setMessage: (message: string, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
export type AlertStore = AlertState & AlertActions;
|
||||
|
||||
interface AlertStoreState extends AlertStore {}
|
||||
|
||||
const useAlertStoreHook = create<AlertStoreState>((set) => ({
|
||||
message: "",
|
||||
callback: undefined,
|
||||
setMessage: (message: string, callback?: () => void) =>
|
||||
set(() => ({ message, callback })),
|
||||
}));
|
||||
|
||||
export const useAlertStore = useAlertStoreHook;
|
||||
export const getAlertStoreState = () => useAlertStoreHook.getState();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
// MenuItem 타입 정의 (필요한 필드만 포함)
|
||||
export interface MenuItem {
|
||||
menuCd: number;
|
||||
menuKornNm: string;
|
||||
menuEngNm: string;
|
||||
parentId: number | null;
|
||||
sortSeq: number;
|
||||
iconNm?: string;
|
||||
children: MenuItem[] | null;
|
||||
levelNo: number;
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
menuHlpCn: string;
|
||||
}
|
||||
|
||||
export interface Tab {
|
||||
id: string; // 고유 ID (menuEngNm)
|
||||
label: string; // 탭에 표시될 이름
|
||||
path: string; // 메뉴 경로
|
||||
urlPath: string; // url 경로
|
||||
parentEngNm: string; // 부모 menuEngNm
|
||||
menuItem: MenuItem; // 메뉴 정보
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
menuList: MenuItem[] | null;
|
||||
addTab: (menuItem: MenuItem) => boolean;
|
||||
removeTab: (tabId: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
setMenuList: (menuList: MenuItem[] | null) => void;
|
||||
closeAllTabs: () => void;
|
||||
closeOtherTabs: (tabId: string) => void;
|
||||
}
|
||||
|
||||
const useTabStoreHook = create<TabStore>((set, get) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
menuList: null,
|
||||
addTab: (menuItem: MenuItem) => {
|
||||
const { tabs, menuList } = get();
|
||||
const tabId = menuItem.menuEngNm || '';
|
||||
|
||||
// 이미 존재하는 탭인지 확인
|
||||
const existingTab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (existingTab) {
|
||||
// 이미 존재하면 활성화만
|
||||
set({ activeTabId: tabId });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tabs.length < 100) {
|
||||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
label: menuItem.menuKornNm,
|
||||
path: menuItem.menuPath,
|
||||
urlPath: menuItem.urlPath,
|
||||
parentEngNm: menuList?.find((m) => m.menuCd === menuItem.parentId)?.menuEngNm || '',
|
||||
menuItem,
|
||||
};
|
||||
|
||||
set({
|
||||
tabs: [...tabs, newTab],
|
||||
activeTabId: tabId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
removeTab: (tabId: string): void => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const newTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
|
||||
// 닫는 탭이 현재 활성 탭이면 다른 탭으로 전환
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (newTabs.length > 0) {
|
||||
// 닫힌 탭의 이전 탭을 활성화, 없으면 다음 탭
|
||||
if (closedTabIndex > 0) {
|
||||
const prevTab = tabs[closedTabIndex - 1];
|
||||
newActiveTabId = prevTab ? prevTab.id : null;
|
||||
} else {
|
||||
const firstTab = newTabs[0];
|
||||
newActiveTabId = firstTab ? firstTab.id : null;
|
||||
}
|
||||
} else {
|
||||
newActiveTabId = null;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
},
|
||||
setActiveTab: (tabId: string) => {
|
||||
set({ activeTabId: tabId });
|
||||
},
|
||||
setMenuList: (menuList: MenuItem[] | null) => {
|
||||
set({ menuList });
|
||||
},
|
||||
closeAllTabs: () => {
|
||||
set({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
},
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
const { tabs } = get();
|
||||
const tabToKeep = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (tabToKeep) {
|
||||
set({
|
||||
tabs: [tabToKeep],
|
||||
activeTabId: tabId,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export const useTabStore = useTabStoreHook;
|
||||
export const getTabStoreState = () => useTabStoreHook.getState();
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from "react";
|
||||
import { ValueType } from "realgrid";
|
||||
|
||||
// RealGrid 타입 정의
|
||||
/** setColumns에 전달하는 컬럼 정보 (footer 배열/단일 객체 포함) */
|
||||
export type RealGridColumnInfo = {
|
||||
name: string;
|
||||
fieldName: string;
|
||||
header: { text: string };
|
||||
editable?: boolean;
|
||||
valueType?: import("realgrid").ValueType;
|
||||
dataFormat?: string;
|
||||
mergeRule?: { criteria: 'value' };
|
||||
footer?: {
|
||||
text?: string;
|
||||
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||
numberFormat?: string;
|
||||
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||
} | Array<{
|
||||
text?: string;
|
||||
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||
numberFormat?: string;
|
||||
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** setColumns 메서드가 있는 GridView */
|
||||
export type GridViewWithSetColumns = GridView & { setColumns?: (columns: RealGridColumnInfo[]) => void };
|
||||
|
||||
/** footer 배열 요소에 valueCallback 할당용 */
|
||||
export type FooterItemWithCallback = { valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string };
|
||||
|
||||
export type GridView = {
|
||||
getSummary(field: string, summary: 'sum' | 'avg' | 'count' | 'max' | 'min'): number | undefined;
|
||||
getDataSource(): {
|
||||
getRowCount(): number;
|
||||
getValue(rowIndex: number, field: string): number | string;
|
||||
getDataRowId(dataRow: number): number | string;
|
||||
};
|
||||
getDataRow(itemIndex: number): number;
|
||||
};
|
||||
|
||||
export type MergeCallback = (grid: GridView, column: DataColumn, itemIndex: number, value: number | string | null | undefined) => number | null;
|
||||
|
||||
export type DataColumn = {
|
||||
name: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export type ColumnFooter = {
|
||||
text?: string;
|
||||
expression?: string;
|
||||
numberFormat?: string;
|
||||
};
|
||||
|
||||
/** 정렬 시 같은 그룹 내에서는 orderByField 순서 유지, 그룹 간에는 정렬 필드로 비교. */
|
||||
export type SortPreserveGroupOrder = {
|
||||
groupField: string;
|
||||
orderByField?: string;
|
||||
};
|
||||
|
||||
/** RealGrid valueCallback 시그니처 (계산 필드용) */
|
||||
export type DataFieldValueCallback = (
|
||||
prod: unknown,
|
||||
dataRow: number,
|
||||
fieldName: string,
|
||||
fieldNames: string[],
|
||||
values: unknown[]
|
||||
) => number | string | undefined;
|
||||
|
||||
/** 계산 필드 설정 (computedFieldCallbacks.ts 참고) */
|
||||
export type ComputedFieldConfig =
|
||||
| { fields: string[]; type: "sum" | "avg" }
|
||||
| { numeratorFields: string[]; denominatorFields: string[]; type: "pblswrgPopltnSuprtRate" }
|
||||
| { numeratorFields: string[]; denominatorFields: string[]; type: "adtrtPopltnSuprtRate" }
|
||||
| { numeratorFields: string[]; denominatorFields: string[]; thirdFields: string[], type: "swrgInstlrtRate" };
|
||||
|
||||
export type ColumnConfig = {
|
||||
name: string;
|
||||
fieldName: string;
|
||||
header: { text: string };
|
||||
/** 계산 필드: fields 합계/평균 등. valueCallback으로 dataProvider.setFields 시 전달 */
|
||||
computed?: ComputedFieldConfig;
|
||||
editable?: boolean; // 셀 편집 가능 여부
|
||||
/** 그리드에서 컬럼 숨김 (수식용 필드 등) */
|
||||
visible?: boolean;
|
||||
mergeCells?: boolean;
|
||||
sortdisable?: boolean;
|
||||
sortPreserveGroupOrder?: SortPreserveGroupOrder;
|
||||
valueType?: ValueType;
|
||||
dataFormat?: string;
|
||||
footer?: {
|
||||
text?: string;
|
||||
expression?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||
numberFormat?: string;
|
||||
valueCallback?: (grid: GridView, column: DataColumn, footerIndex: number, columnFooter: ColumnFooter, value: number | string | undefined) => number | string;
|
||||
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 | {
|
||||
name: string;
|
||||
hideChildHeaders?: boolean;
|
||||
header: { text: string };
|
||||
items: (string | ColumnLayoutItem)[];
|
||||
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
|
||||
} | {
|
||||
column: string; // 컬럼 이름 (footerUserSpans 사용 시)
|
||||
footerUserSpans?: FooterUserSpan[]; // footer 병합 설정
|
||||
};
|
||||
|
||||
export type ColumnInfo = {
|
||||
columns: ColumnConfig[];
|
||||
columnLayout: ColumnLayoutItem[];
|
||||
};
|
||||
|
||||
export type ColumnInfoList = {
|
||||
tabKey: string; // 탭을 식별하는 고유 키 (필수), server(api)에 전송할 변수 값
|
||||
columns?: ColumnConfig[]; // 일반 그리드용 (isSplitGrid가 false일 때)
|
||||
columnLayout?: ColumnLayoutItem[]; // 일반 그리드용 (isSplitGrid가 false일 때)
|
||||
rowStartNum?: number;
|
||||
fixedColumns?: number;
|
||||
tabLabel?: string; // 탭에 표시될 레이블
|
||||
isSplitGrid?: boolean; // 5:5 분리형 그리드 유무
|
||||
left?: ColumnInfoList; // SplitDataGrid용 왼쪽 그리드 정보
|
||||
right?: ColumnInfoList; // SplitDataGrid용 오른쪽 그리드 정보
|
||||
}
|
||||
|
||||
export type DataGridProps = {
|
||||
columnInfo: ColumnInfo;
|
||||
rowStartNum?: number; // 헤더 행 개수 (1부터 시작, 예: 2면 1행과 2행까지 헤더로 간주)
|
||||
isExcelImport?: boolean; // 엑셀 가져오기 유무
|
||||
isExcelExport?: boolean;
|
||||
onRowsChange?: (rows: Record<string, string | number>[]) => void;
|
||||
fixedColumns?: number; // 고정할 컬럼 개수 (좌측부터)
|
||||
showHeaderCheckbox?: boolean; // 헤더 체크박스 표시 여부 (기본값: true)
|
||||
rowData: Record<string, string | number>[];
|
||||
setRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>;
|
||||
};
|
||||
|
||||
export type TabDataGridProps = {
|
||||
columnInfo: ColumnInfoList[];
|
||||
rows?: Record<string, string | number>[];
|
||||
isExcelImport?: boolean; // 엑셀 가져오기 유무
|
||||
isExcelExport?: boolean;
|
||||
onRowsChange?: (rows: Record<string, string | number>[]) => void;
|
||||
showHeaderCheckbox?: boolean; // 헤더 체크박스 표시 여부 (기본값: true)
|
||||
rowDataMap: Map<string, Record<string, string | number>[]>;
|
||||
setRowDataMap: React.Dispatch<React.SetStateAction<Map<string, Record<string, string | number>[]>>>;
|
||||
isSplitGrid?: boolean; // 5:5 분리형 그리드 유무
|
||||
};
|
||||
|
||||
export type SplitDataGridProps = {
|
||||
leftColumnInfo: ColumnInfo;
|
||||
rightColumnInfo: ColumnInfo;
|
||||
leftFixedColumns?: number;
|
||||
rightFixedColumns?: number;
|
||||
showHeaderCheckbox?: boolean;
|
||||
leftRowData: Record<string, string | number>[];
|
||||
rightRowData: Record<string, string | number>[];
|
||||
setLeftRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>;
|
||||
setRightRowData: React.Dispatch<React.SetStateAction<Record<string, string | number>[]>>;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { DataFieldValueCallback, ComputedFieldConfig } from './DataGrid.type'
|
||||
|
||||
/** 값 → 숫자 변환 (null/undefined/빈문자열 → 0) */
|
||||
function toNum(v: unknown): number {
|
||||
if (v == null || v === "") return 0;
|
||||
if (typeof v === "number") return isNaN(v) ? 0 : v;
|
||||
const n = parseFloat(String(v).replace(/,/g, ""));
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
/** 합계용 valueCallback 생성 */
|
||||
export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback {
|
||||
return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => {
|
||||
let sum = 0;
|
||||
for (const f of fieldNames) {
|
||||
const idx = fieldNamesArr.indexOf(f);
|
||||
if (idx >= 0) sum += toNum(values[idx]);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
}
|
||||
|
||||
/** 평균용 valueCallback 생성 */
|
||||
export function createAvgValueCallback(fieldNames: string[]): DataFieldValueCallback {
|
||||
return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const f of fieldNames) {
|
||||
const idx = fieldNamesArr.indexOf(f);
|
||||
if (idx >= 0) {
|
||||
const val = toNum(values[idx]);
|
||||
sum += val;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : 0;
|
||||
};
|
||||
}
|
||||
|
||||
// 공공하수처리구역인구보급률(%), 고도처리인구보급률(%) valueCallback
|
||||
export function createCoverageRateValueCallback(
|
||||
numeratorFields: string[],
|
||||
denominatorFields: string[]
|
||||
): DataFieldValueCallback {
|
||||
return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => {
|
||||
const getSum = (fields: string[]) => {
|
||||
let sum = 0;
|
||||
for (const f of fields) {
|
||||
const idx = fieldNamesArr.indexOf(f);
|
||||
if (idx >= 0) sum += toNum(values[idx]);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const total2 = getSum(numeratorFields);
|
||||
const total1 = getSum(denominatorFields);
|
||||
return total1 === 0 ? 0 : (total2 / total1) * 100;
|
||||
};
|
||||
}
|
||||
|
||||
// 하수도 설치율(%) valueCallback
|
||||
export function createSwrgInstlrtRateValueCallback(
|
||||
numeratorFields: string[],
|
||||
denominatorFields: string[],
|
||||
thirdFields: string[],
|
||||
): DataFieldValueCallback {
|
||||
return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => {
|
||||
const getSum = (fields: string[]) => {
|
||||
let sum = 0;
|
||||
for (const f of fields) {
|
||||
const idx = fieldNamesArr.indexOf(f);
|
||||
if (idx >= 0) sum += toNum(values[idx]);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const sum1 = getSum(numeratorFields);
|
||||
const sum2 = getSum(denominatorFields);
|
||||
|
||||
const piplSupprt = toNum(values[fieldNamesArr.indexOf(thirdFields[0] || 'piplSupprt')]);
|
||||
|
||||
const total1 = (sum1 === 0 ? 0 : Math.round(sum2 / sum1 * 100));
|
||||
|
||||
return total1 * piplSupprt / 100;
|
||||
};
|
||||
}
|
||||
|
||||
/** 연산 타입에 맞는 valueCallback 반환 */
|
||||
export function getComputedValueCallback(config: ComputedFieldConfig): DataFieldValueCallback {
|
||||
switch (config.type) {
|
||||
case 'sum':
|
||||
return createSumValueCallback(config.fields);
|
||||
case 'avg':
|
||||
return createAvgValueCallback(config.fields);
|
||||
case 'pblswrgPopltnSuprtRate':
|
||||
case 'adtrtPopltnSuprtRate':
|
||||
return createCoverageRateValueCallback(config.numeratorFields, config.denominatorFields);
|
||||
case 'swrgInstlrtRate':
|
||||
return createSwrgInstlrtRateValueCallback(config.numeratorFields, config.denominatorFields, config.thirdFields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
} from "@common/components";
|
||||
import { useAlertStore } from "../store/alert-store";
|
||||
|
||||
function AlertDialog() {
|
||||
const { message, callback, setMessage } = useAlertStore((state) => state);
|
||||
const handleOpenChange = () => {
|
||||
setMessage("", undefined);
|
||||
callback?.();
|
||||
};
|
||||
return (
|
||||
<Dialog open={!!message} onOpenChange={handleOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
<Button>확인</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AlertDialog };
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@rsone/components",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./index.css": "./dist/index.css"
|
||||
},
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/components": "workspace:*",
|
||||
"realgrid": "^2.9.5",
|
||||
"realgrid-react": "^0.9.7",
|
||||
"jszip": "^3.10.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "8.3.6",
|
||||
"typescript": "~5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": false,
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"components/*"
|
||||
],
|
||||
"@common/components": [
|
||||
"../common/components/src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
"**/.*/*",
|
||||
"dist"
|
||||
],
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig((options) => ({
|
||||
entry: ["components/index.ts"],
|
||||
splitting: true,
|
||||
treeshake: true,
|
||||
clean: true,
|
||||
dts: true,
|
||||
format: ["esm"],
|
||||
external: ["react", "react-dom", "realgrid", "realgrid-react"],
|
||||
platform: "browser",
|
||||
target: ["es2020"],
|
||||
tsconfig: path.resolve(__dirname, "tsconfig.build.json"),
|
||||
sourcemap: !options.watch,
|
||||
minify: !options.watch,
|
||||
}));
|
||||
Reference in New Issue
Block a user