feat: 그리드 텝 형식 컴포넌트 신규 생성
TabDataGrid.tsx(탭 형석 그리드) & SewagePipeColumn.ts(컬럼정보) 예시 화면 - 하수도 통계관리/하수관로
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user