feat: 그리드 텝 형식 컴포넌트 신규 생성

TabDataGrid.tsx(탭 형석 그리드) & SewagePipeColumn.ts(컬럼정보)
예시 화면 - 하수도 통계관리/하수관로
This commit is contained in:
2025-12-23 12:05:03 +09:00
parent 019415f124
commit ba4062f826
19 changed files with 798 additions and 4 deletions
@@ -0,0 +1,352 @@
import { useRef, useEffect, useState } from "react"
import RealGrid, { GridFitStyle } from "realgrid"
import { RealGridReact, RGDataColumn } from "realgrid-react"
import type { TabDataGridProps } from "../type/DataGrid.type"
import JSZip from "jszip"
import * as XLSX from "xlsx"
import { useAlertStore } from "@/shared/lib";
import { useTabStore } from "@/store/tab-store"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components";
import { Button } from "@common/components";
// 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 || '');
const TabDataGrid = ({ columnInfo, isExcelImport = true, rows = [] }: TabDataGridProps) => {
// 각 탭별로 그리드 ref와 데이터를 관리
const gridRefs = useRef<Map<string, RealGridReact>>(new Map());
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
const { setMessage: alert } = useAlertStore((state) => state);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
// 각 탭별로 데이터를 관리 (key: tabKey)
const [rowDataMap, setRowDataMap] = useState<Map<string, Record<string, string>[]>>(new Map());
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;
// 데이터 행 읽기
const dataRows: Record<string, string>[] = [];
for (let row = dataStartRow; row <= range.e.r; row++) {
const rowData: Record<string, string> = {};
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}`;
const cellValue = cell && cell.v !== undefined && cell.v !== null
? String(cell.v)
: '';
if (cellValue !== '') {
hasData = true;
}
rowData[headerName] = 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]);
// 활성 탭이 변경될 때마다 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;
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"
type="button"
onClick={() => importExcel(tabKey)}
>
<i className="diveicon di-file-excel" />
<span>가져오기</span>
</Button>
)}
<Button
className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover"
type="button"
onClick={() => exportExcel(tabKey)}
>
<i className="diveicon di-file-excel" />
<span>내보내기</span>
</Button>
</div>
<div className="h-[60vh]">
<RealGridReact
ref={(el) => {
if (el) {
gridRefs.current.set(tabKey, el);
// 그리드가 생성된 직후 fitStyle 설정
if (el.gridView) {
setTimeout(() => {
const gridView = el.gridView;
if (!gridView) return;
gridView.setDisplayOptions({
fitStyle: GridFitStyle.EVEN
});
gridView.header.height = 60;
// 고정 컬럼 설정
const currentInfo = columnInfo.find(info => info.tabKey === tabKey);
if (currentInfo?.fixedColumns !== undefined && currentInfo.fixedColumns > 0) {
gridView.setFixedOptions({
colCount: currentInfo.fixedColumns,
});
}
}, 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}
header={column.header}
/>
))}
</RealGridReact>
</div>
</div>
</TabsContent>
);
})}
</Tabs>
</div>
);
};
export default TabDataGrid;