"use client"; /** * PivotGrid 메인 컴포넌트 * 다차원 데이터 분석을 위한 피벗 테이블 */ import React, { useState, useMemo, useCallback } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, PivotResult, PivotFieldConfig, PivotCellData, PivotFlatRow, PivotCellValue, PivotGridState, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; import { ChevronRight, ChevronDown, Download, Settings, RefreshCw, Maximize2, Minimize2, } from "lucide-react"; import { Button } from "@/components/ui/button"; // ==================== 서브 컴포넌트 ==================== // 행 헤더 셀 interface RowHeaderCellProps { row: PivotFlatRow; rowFields: PivotFieldConfig[]; onToggleExpand: (path: string[]) => void; } const RowHeaderCell: React.FC = ({ row, rowFields, onToggleExpand, }) => { const indentSize = row.level * 20; return (
{row.hasChildren && ( )} {!row.hasChildren && } {row.caption}
); }; // 데이터 셀 interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; onClick?: () => void; } const DataCell: React.FC = ({ values, isTotal = false, onClick, }) => { if (!values || values.length === 0) { return ( - ); } // 단일 데이터 필드인 경우 if (values.length === 1) { return ( {values[0].formattedValue} ); } // 다중 데이터 필드인 경우 return ( <> {values.map((val, idx) => ( {val.formattedValue} ))} ); }; // ==================== 메인 컴포넌트 ==================== export const PivotGridComponent: React.FC = ({ title, fields = [], totals = { showRowGrandTotals: true, showColumnGrandTotals: true, showRowTotals: true, showColumnTotals: true, }, style = { theme: "default", headerStyle: "default", cellPadding: "normal", borderStyle: "light", alternateRowColors: true, highlightTotals: true, }, allowExpandAll = true, height = "auto", maxHeight, exportConfig, data: externalData, onCellClick, onExpandChange, }) => { // ==================== 상태 ==================== const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); const [isFullscreen, setIsFullscreen] = useState(false); // 데이터 const data = externalData || []; // ==================== 필드 분류 ==================== const rowFields = useMemo( () => fields .filter((f) => f.area === "row" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), [fields] ); const columnFields = useMemo( () => fields .filter((f) => f.area === "column" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), [fields] ); const dataFields = useMemo( () => fields .filter((f) => f.area === "data" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), [fields] ); // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { if (!data || data.length === 0 || fields.length === 0) { return null; } return processPivotData( data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // ==================== 이벤트 핸들러 ==================== // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { setPivotState((prev) => { const pathKey = pathToKey(path); const existingIndex = prev.expandedRowPaths.findIndex( (p) => pathToKey(p) === pathKey ); let newPaths: string[][]; if (existingIndex >= 0) { newPaths = prev.expandedRowPaths.filter( (_, i) => i !== existingIndex ); } else { newPaths = [...prev.expandedRowPaths, path]; } onExpandChange?.(newPaths); return { ...prev, expandedRowPaths: newPaths, }; }); }, [onExpandChange] ); // 전체 확장 const handleExpandAll = useCallback(() => { if (!pivotResult) return; const allRowPaths: string[][] = []; pivotResult.flatRows.forEach((row) => { if (row.hasChildren) { allRowPaths.push(row.path); } }); setPivotState((prev) => ({ ...prev, expandedRowPaths: allRowPaths, expandedColumnPaths: [], })); }, [pivotResult]); // 전체 축소 const handleCollapseAll = useCallback(() => { setPivotState((prev) => ({ ...prev, expandedRowPaths: [], expandedColumnPaths: [], })); }, []); // 셀 클릭 const handleCellClick = useCallback( (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { if (!onCellClick) return; const cellData: PivotCellData = { value: values[0]?.value, rowPath, columnPath: colPath, field: values[0]?.field, }; onCellClick(cellData); }, [onCellClick] ); // CSV 내보내기 const handleExportCSV = useCallback(() => { if (!pivotResult) return; const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; let csv = ""; // 헤더 행 const headerRow = [""].concat( flatColumns.map((col) => col.caption || "총계") ); if (totals?.showRowGrandTotals) { headerRow.push("총계"); } csv += headerRow.join(",") + "\n"; // 데이터 행 flatRows.forEach((row) => { const rowData = [row.caption]; flatColumns.forEach((col) => { const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; const values = dataMatrix.get(cellKey); rowData.push(values?.[0]?.value?.toString() || ""); }); if (totals?.showRowGrandTotals) { const rowTotal = grandTotals.row.get(pathToKey(row.path)); rowData.push(rowTotal?.[0]?.value?.toString() || ""); } csv += rowData.join(",") + "\n"; }); // 열 총계 행 if (totals?.showColumnGrandTotals) { const totalRow = ["총계"]; flatColumns.forEach((col) => { const colTotal = grandTotals.column.get(pathToKey(col.path)); totalRow.push(colTotal?.[0]?.value?.toString() || ""); }); if (totals?.showRowGrandTotals) { totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); } csv += totalRow.join(",") + "\n"; } // 다운로드 const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;", }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = `${title || "pivot"}_export.csv`; link.click(); }, [pivotResult, totals, title]); // ==================== 렌더링 ==================== // 빈 상태 if (!data || data.length === 0) { return (

데이터가 없습니다

데이터를 로드하거나 필드를 설정해주세요

); } // 필드 미설정 if (fields.length === 0) { return (

필드가 설정되지 않았습니다

행, 열, 데이터 영역에 필드를 배치해주세요

); } // 피벗 결과 없음 if (!pivotResult) { return (
); } const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; return (
{/* 헤더 툴바 */}
{title &&

{title}

} ({data.length}건)
{allowExpandAll && ( <> )} {exportConfig?.excel && ( )}
{/* 피벗 테이블 */}
{/* 열 헤더 */} {/* 좌상단 코너 (행 필드 라벨) */} {/* 열 헤더 셀 */} {flatColumns.map((col, idx) => ( ))} {/* 행 총계 헤더 */} {totals?.showRowGrandTotals && ( )} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {dataFields.length > 1 && ( {flatColumns.map((col, colIdx) => ( {dataFields.map((df, dfIdx) => ( ))} ))} {totals?.showRowGrandTotals && dataFields.map((df, dfIdx) => ( ))} )} {flatRows.map((row, rowIdx) => ( {/* 행 헤더 */} {/* 데이터 셀 */} {flatColumns.map((col, colIdx) => { const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; const values = dataMatrix.get(cellKey) || []; return ( handleCellClick(row.path, col.path, values) : undefined } /> ); })} {/* 행 총계 */} {totals?.showRowGrandTotals && ( )} ))} {/* 열 총계 행 */} {totals?.showColumnGrandTotals && ( {flatColumns.map((col, colIdx) => ( ))} {/* 대총합 */} {totals?.showRowGrandTotals && ( )} )}
0 ? 2 : 1} > {rowFields.map((f) => f.caption).join(" / ") || "항목"} {col.caption || "(전체)"} 총계
{df.caption} {df.caption}
총계
); }; export default PivotGridComponent;