"use client"; /** * PivotGrid 메인 컴포넌트 * 다차원 데이터 분석을 위한 피벗 테이블 */ import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, PivotResult, PivotFieldConfig, PivotCellData, PivotFlatRow, PivotCellValue, PivotGridState, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; import { exportPivotToExcel } from "./utils/exportExcel"; import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; import { FieldPanel } from "./components/FieldPanel"; import { FieldChooser } from "./components/FieldChooser"; import { DrillDownModal } from "./components/DrillDownModal"; import { PivotChart } from "./components/PivotChart"; import { FilterPopup } from "./components/FilterPopup"; import { useVirtualScroll } from "./hooks/useVirtualScroll"; import { ChevronRight, ChevronDown, Download, Settings, RefreshCw, Maximize2, Minimize2, LayoutGrid, FileSpreadsheet, BarChart3, Filter, ArrowUp, ArrowDown, ArrowUpDown, Printer, Save, RotateCcw, FileText, Loader2, Eye, EyeOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; // ==================== 유틸리티 함수 ==================== // 셀 병합 정보 계산 interface MergeCellInfo { rowSpan: number; skip: boolean; // 병합된 셀에서 건너뛸지 여부 } const calculateMergeCells = ( rows: PivotFlatRow[], mergeCells: boolean ): Map => { const mergeInfo = new Map(); if (!mergeCells || rows.length === 0) { rows.forEach((_, idx) => mergeInfo.set(idx, { rowSpan: 1, skip: false })); return mergeInfo; } let i = 0; while (i < rows.length) { const currentPath = rows[i].path.join("|||"); let spanCount = 1; // 같은 path를 가진 연속 행 찾기 while ( i + spanCount < rows.length && rows[i + spanCount].path.join("|||") === currentPath ) { spanCount++; } // 첫 번째 행은 rowSpan 설정 mergeInfo.set(i, { rowSpan: spanCount, skip: false }); // 나머지 행은 skip for (let j = 1; j < spanCount; j++) { mergeInfo.set(i + j, { rowSpan: 1, skip: true }); } i += spanCount; } return mergeInfo; }; // ==================== 서브 컴포넌트 ==================== // 행 헤더 셀 interface RowHeaderCellProps { row: PivotFlatRow; rowFields: PivotFieldConfig[]; onToggleExpand: (path: string[]) => void; rowSpan?: number; } const RowHeaderCell: React.FC = ({ row, rowFields, onToggleExpand, rowSpan = 1, }) => { const indentSize = row.level * 20; return ( 1 ? rowSpan : undefined} >
{row.hasChildren && ( )} {!row.hasChildren && } {row.caption}
); }; // 데이터 셀 interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; isSelected?: boolean; onClick?: (e?: React.MouseEvent) => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; } const DataCell: React.FC = ({ values, isTotal = false, isSelected = false, onClick, onDoubleClick, conditionalStyle, }) => { // 조건부 서식 스타일 계산 const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; const icon = conditionalStyle?.icon; // 선택 상태 스타일 const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10"; if (!values || values.length === 0) { return ( 0 ); } // 툴팁 내용 생성 const tooltipContent = values.map((v) => `${v.field || "값"}: ${v.formattedValue || v.value}` ).join("\n"); // 단일 데이터 필드인 경우 if (values.length === 1) { return ( {/* Data Bar */} {hasDataBar && (
)} {icon && {icon}} {values[0].formattedValue || (values[0].value === 0 ? '0' : values[0].formattedValue)} ); } // 다중 데이터 필드인 경우 return ( <> {values.map((val, idx) => ( {hasDataBar && (
)} {icon && {icon}} {val.formattedValue || (val.value === 0 ? '0' : val.formattedValue)} ))} ); }; // ==================== 메인 컴포넌트 ==================== export const PivotGridComponent: React.FC = ({ title, fields: initialFields = [], totals = { showRowGrandTotals: true, showColumnGrandTotals: true, showRowTotals: true, showColumnTotals: true, }, style = { theme: "default", headerStyle: "default", cellPadding: "normal", borderStyle: "light", alternateRowColors: true, highlightTotals: true, }, fieldChooser, chart: chartConfig, allowExpandAll = true, height = "auto", maxHeight, exportConfig, data: externalData, onCellClick, onCellDoubleClick, onFieldDrop, onExpandChange, }) => { // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); // 🆕 초기 로드 시 자동 확장 (첫 레벨만) const [isInitialExpanded, setIsInitialExpanded] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태 const [showFieldChooser, setShowFieldChooser] = useState(false); const [drillDownData, setDrillDownData] = useState<{ open: boolean; cellData: PivotCellData | null; }>({ open: false, cellData: null }); const [showChart, setShowChart] = useState(chartConfig?.enabled || false); const [containerHeight, setContainerHeight] = useState(400); const tableContainerRef = useRef(null); // 셀 선택 상태 (범위 선택 지원) const [selectedCell, setSelectedCell] = useState<{ rowIndex: number; colIndex: number; } | null>(null); const [selectionRange, setSelectionRange] = useState<{ startRow: number; startCol: number; endRow: number; endCol: number; } | null>(null); const tableRef = useRef(null); // 정렬 상태 const [sortConfig, setSortConfig] = useState<{ field: string; direction: "asc" | "desc"; } | null>(null); // 열 너비 상태 const [columnWidths, setColumnWidths] = useState>({}); const [resizingColumn, setResizingColumn] = useState(null); const [resizeStartX, setResizeStartX] = useState(0); const [resizeStartWidth, setResizeStartWidth] = useState(0); // 외부 fields 변경 시 동기화 useEffect(() => { if (initialFields.length > 0) { setFields(initialFields); } }, [initialFields]); // 상태 저장 키 const stateStorageKey = `pivot-state-${title || "default"}`; // 상태 저장 (localStorage) const saveStateToStorage = useCallback(() => { if (typeof window === "undefined") return; const stateToSave = { fields, pivotState, sortConfig, columnWidths, }; localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); // 상태 복원 (localStorage) - 프로덕션 안전성 강화 useEffect(() => { if (typeof window === "undefined") return; try { const savedState = localStorage.getItem(stateStorageKey); if (!savedState) return; const parsed = JSON.parse(savedState); // 버전 체크 - 버전이 다르면 이전 상태 무시 if (parsed.version !== PIVOT_STATE_VERSION) { localStorage.removeItem(stateStorageKey); return; } // 필드 복원 시 유효성 검사 (중요!) if (parsed.fields && Array.isArray(parsed.fields) && parsed.fields.length > 0) { // 저장된 필드가 현재 데이터와 호환되는지 확인 const validFields = parsed.fields.filter((f: PivotFieldConfig) => f && typeof f.field === "string" && typeof f.area === "string" ); if (validFields.length > 0) { setFields(validFields); } } // pivotState 복원 시 유효성 검사 (확장 경로 검증) if (parsed.pivotState && typeof parsed.pivotState === "object") { const restoredState: PivotGridState = { // expandedRowPaths는 배열의 배열이어야 함 expandedRowPaths: Array.isArray(parsed.pivotState.expandedRowPaths) ? parsed.pivotState.expandedRowPaths.filter( (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") ) : [], // expandedColumnPaths도 동일하게 검증 expandedColumnPaths: Array.isArray(parsed.pivotState.expandedColumnPaths) ? parsed.pivotState.expandedColumnPaths.filter( (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") ) : [], sortConfig: parsed.pivotState.sortConfig || null, filterConfig: parsed.pivotState.filterConfig || {}, }; setPivotState(restoredState); } if (parsed.sortConfig) setSortConfig(parsed.sortConfig); if (parsed.columnWidths && typeof parsed.columnWidths === "object") { setColumnWidths(parsed.columnWidths); } } catch (e) { console.warn("피벗 상태 복원 실패, localStorage 초기화:", e); // 손상된 상태는 제거 localStorage.removeItem(stateStorageKey); } }, [stateStorageKey]); // 데이터 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 filterFields = useMemo( () => { const result = fields .filter((f) => f.area === "filter" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); return result; }, [fields] ); // 사용 가능한 필드 목록 (FieldChooser용) const availableFields = useMemo(() => { if (data.length === 0) return []; const sampleRow = data[0]; return Object.keys(sampleRow).map((key) => { const existingField = fields.find((f) => f.field === key); const value = sampleRow[key]; // 데이터 타입 추론 let dataType: "string" | "number" | "date" | "boolean" = "string"; if (typeof value === "number") dataType = "number"; else if (typeof value === "boolean") dataType = "boolean"; else if (value instanceof Date) dataType = "date"; else if (typeof value === "string") { // 날짜 문자열 감지 if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; } return { field: key, caption: existingField?.caption || key, dataType, isSelected: existingField?.visible !== false, currentArea: existingField?.area, }; }); }, [data, fields]); // ==================== 필터 적용 ==================== const filteredData = useMemo(() => { if (!data || data.length === 0) return data; // 필터 영역의 필드들로 데이터 필터링 const activeFilters = fields.filter( (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 ); if (activeFilters.length === 0) return data; const result = data.filter((row) => { return activeFilters.every((filter) => { const rawValue = row[filter.field]; const filterValues = filter.filterValues || []; const filterType = filter.filterType || "include"; // 타입 안전한 비교: 값을 문자열로 변환하여 비교 const value = rawValue === null || rawValue === undefined ? "(빈 값)" : String(rawValue); if (filterType === "include") { return filterValues.some((fv) => String(fv) === value); } else { return filterValues.every((fv) => String(fv) !== value); } }); }); // 모든 데이터가 필터링되면 경고 (디버깅용) if (result.length === 0 && data.length > 0) { console.warn("⚠️ [PivotGrid] 필터로 인해 모든 데이터가 제거됨"); } return result; }, [data, fields]); // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { try { if (!filteredData || filteredData.length === 0 || fields.length === 0) { return null; } // FieldChooser에서 이미 필드를 완전히 제거하므로 visible 필터링 불필요 // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) if (fields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { return null; } const result = processPivotData( filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); return result; } catch (error) { console.error("❌ [pivotResult] 피벗 처리 에러:", error); return null; } }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // 초기 로드 시 첫 레벨 자동 확장 useEffect(() => { try { if (pivotResult && pivotResult.flatRows && pivotResult.flatRows.length > 0 && !isInitialExpanded) { // 첫 레벨 행들의 경로 수집 (level 0인 행들) const firstLevelRows = pivotResult.flatRows.filter((row) => row.level === 0 && row.hasChildren); // 첫 레벨 행이 있으면 자동 확장 if (firstLevelRows.length > 0 && firstLevelRows.length < 100) { const firstLevelPaths = firstLevelRows.map((row) => row.path); setPivotState((prev) => ({ ...prev, expandedRowPaths: firstLevelPaths, })); setIsInitialExpanded(true); } else { // 행이 너무 많으면 자동 확장 건너뛰기 setIsInitialExpanded(true); } } } catch (error) { console.error("❌ [초기 확장] 에러:", error); setIsInitialExpanded(true); } }, [pivotResult, isInitialExpanded]); // 조건부 서식용 전체 값 수집 const allCellValues = useMemo(() => { if (!pivotResult) return new Map(); const valuesByField = new Map(); // 데이터 매트릭스에서 모든 값 수집 pivotResult.dataMatrix.forEach((values) => { values.forEach((val) => { if (val.field && typeof val.value === "number" && !isNaN(val.value)) { const existing = valuesByField.get(val.field) || []; existing.push(val.value); valuesByField.set(val.field, existing); } }); }); // 행 총계 값 수집 pivotResult.grandTotals.row.forEach((values) => { values.forEach((val) => { if (val.field && typeof val.value === "number" && !isNaN(val.value)) { const existing = valuesByField.get(val.field) || []; existing.push(val.value); valuesByField.set(val.field, existing); } }); }); // 열 총계 값 수집 pivotResult.grandTotals.column.forEach((values) => { values.forEach((val) => { if (val.field && typeof val.value === "number" && !isNaN(val.value)) { const existing = valuesByField.get(val.field) || []; existing.push(val.value); valuesByField.set(val.field, existing); } }); }); return valuesByField; }, [pivotResult]); // ==================== 가상 스크롤 ==================== const ROW_HEIGHT = 32; // 행 높이 (px) const VIRTUAL_SCROLL_THRESHOLD = 50; // 이 행 수 이상이면 가상 스크롤 활성화 // 컨테이너 높이 측정 useEffect(() => { if (!tableContainerRef.current) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setContainerHeight(entry.contentRect.height); } }); observer.observe(tableContainerRef.current); return () => observer.disconnect(); }, []); // 열 크기 조절 중 useEffect(() => { if (resizingColumn === null) return; const handleMouseMove = (e: MouseEvent) => { const diff = e.clientX - resizeStartX; const newWidth = Math.max(50, resizeStartWidth + diff); // 최소 50px setColumnWidths((prev) => ({ ...prev, [resizingColumn]: newWidth, })); }; const handleMouseUp = () => { setResizingColumn(null); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [resizingColumn, resizeStartX, resizeStartWidth]); // 가상 스크롤 훅 사용 const flatRows = pivotResult?.flatRows || []; // 정렬된 행 데이터 const sortedFlatRows = useMemo(() => { if (!sortConfig || !pivotResult) return flatRows; const { field, direction } = sortConfig; const { dataMatrix, flatColumns } = pivotResult; // 각 행의 정렬 기준 값 계산 const rowsWithSortValue = flatRows.map((row) => { let sortValue = 0; // 모든 열에 대해 해당 필드의 합계 계산 flatColumns.forEach((col) => { const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; const values = dataMatrix.get(cellKey) || []; const targetValue = values.find((v) => v.field === field); if (targetValue?.value != null) { sortValue += targetValue.value; } }); return { row, sortValue }; }); // 정렬 rowsWithSortValue.sort((a, b) => { if (direction === "asc") { return a.sortValue - b.sortValue; } return b.sortValue - a.sortValue; }); return rowsWithSortValue.map((item) => item.row); }, [flatRows, sortConfig, pivotResult]); const enableVirtualScroll = sortedFlatRows.length > VIRTUAL_SCROLL_THRESHOLD; const virtualScroll = useVirtualScroll({ itemCount: sortedFlatRows.length, itemHeight: ROW_HEIGHT, containerHeight: containerHeight, overscan: 10, }); // 가상 스크롤 적용된 행 데이터 const visibleFlatRows = useMemo(() => { if (!enableVirtualScroll) return sortedFlatRows; return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); // 조건부 서식 스타일 계산 헬퍼 const getCellConditionalStyle = useCallback( (value: number | undefined, field: string): CellFormatStyle => { if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { return {}; } const allValues = allCellValues.get(field) || []; return getConditionalStyle(value, field, style.conditionalFormats, allValues); }, [style?.conditionalFormats, allCellValues] ); // ==================== 이벤트 핸들러 ==================== // 필드 변경 const handleFieldsChange = useCallback( (newFields: PivotFieldConfig[]) => { setFields(newFields); }, [] ); // 행 확장/축소 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(() => { try { if (!pivotResult) { return; } // 재귀적으로 모든 가능한 경로 생성 const allRowPaths: string[][] = []; const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false); // 행 필드가 없으면 종료 if (rowFields.length === 0) { return; } // 데이터에서 모든 고유한 경로 추출 const pathSet = new Set(); filteredData.forEach((item) => { // 마지막 레벨은 제외 (확장할 자식이 없으므로) for (let depth = 1; depth < rowFields.length; depth++) { const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? "")); const pathKey = JSON.stringify(path); pathSet.add(pathKey); } }); // Set을 배열로 변환 (최대 1000개로 제한하여 성능 보호) const MAX_PATHS = 1000; let count = 0; pathSet.forEach((pathKey) => { if (count < MAX_PATHS) { allRowPaths.push(JSON.parse(pathKey)); count++; } }); setPivotState((prev) => ({ ...prev, expandedRowPaths: allRowPaths, expandedColumnPaths: [], })); } catch (error) { console.error("❌ [handleExpandAll] 에러:", error); } }, [pivotResult, fields, filteredData]); // 전체 축소 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] ); // 셀 더블클릭 (Drill Down) const handleCellDoubleClick = useCallback( (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { const cellData: PivotCellData = { value: values[0]?.value, rowPath, columnPath: colPath, field: values[0]?.field, }; // Drill Down 모달 열기 setDrillDownData({ open: true, cellData }); // 외부 콜백 호출 if (onCellDoubleClick) { onCellDoubleClick(cellData); } }, [onCellDoubleClick] ); // 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]); // Excel 내보내기 const handleExportExcel = useCallback(async () => { if (!pivotResult) return; try { await exportPivotToExcel(pivotResult, fields, totals, { fileName: title || "pivot_export", title: title, }); } catch (error) { console.error("Excel 내보내기 실패:", error); } }, [pivotResult, fields, totals, title]); // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) const handlePrint = useCallback(() => { if (typeof window === "undefined") return; const printContent = tableRef.current; if (!printContent) return; const printWindow = window.open("", "_blank"); if (!printWindow) return; printWindow.document.write(` ${title || "피벗 테이블"}

${title || "피벗 테이블"}

${printContent.outerHTML} `); printWindow.document.close(); printWindow.focus(); setTimeout(() => { printWindow.print(); printWindow.close(); }, 250); }, [title]); // PDF 내보내기 const handleExportPDF = useCallback(async () => { if (!pivotResult || !tableRef.current) return; try { // 동적 import로 jspdf와 html2canvas 로드 const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ import("jspdf"), import("html2canvas"), ]); const canvas = await html2canvas(tableRef.current, { scale: 2, useCORS: true, logging: false, }); const imgData = canvas.toDataURL("image/png"); const pdf = new jsPDF({ orientation: canvas.width > canvas.height ? "landscape" : "portrait", unit: "px", format: [canvas.width, canvas.height], }); pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height); pdf.save(`${title || "pivot"}_export.pdf`); } catch (error) { console.error("PDF 내보내기 실패:", error); // jspdf가 없으면 인쇄 대화상자로 대체 handlePrint(); } }, [pivotResult, title, handlePrint]); // 데이터 새로고침 const [isRefreshing, setIsRefreshing] = useState(false); const handleRefreshData = useCallback(async () => { setIsRefreshing(true); // 외부 데이터 소스가 있으면 새로고침 // 여기서는 상태만 초기화 setPivotState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); setSortConfig(null); setSelectedCell(null); setSelectionRange(null); setTimeout(() => setIsRefreshing(false), 500); }, []); // 상태 저장 버튼 핸들러 const handleSaveState = useCallback(() => { saveStateToStorage(); console.log("피벗 상태가 저장되었습니다."); }, [saveStateToStorage]); // 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지) const handleResetState = useCallback(() => { // 로컬 스토리지에서 상태 제거 (SSR 보호) if (typeof window !== "undefined") { localStorage.removeItem(stateStorageKey); } // 확장/축소, 정렬, 필터 상태만 초기화 setPivotState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); setSortConfig(null); setColumnWidths({}); setSelectedCell(null); setSelectionRange(null); }, [stateStorageKey]); // 필드 숨기기/표시 상태 const [hiddenFields, setHiddenFields] = useState>(new Set()); const toggleFieldVisibility = useCallback((fieldName: string) => { setHiddenFields((prev) => { const newSet = new Set(prev); if (newSet.has(fieldName)) { newSet.delete(fieldName); } else { newSet.add(fieldName); } return newSet; }); }, []); // 숨겨진 필드 목록 const hiddenFieldsList = useMemo(() => { return fields.filter((f) => hiddenFields.has(f.field)); }, [fields, hiddenFields]); // 모든 필드 표시 const showAllFields = useCallback(() => { setHiddenFields(new Set()); }, []); // ==================== 렌더링 ==================== // 빈 상태 if (!data || data.length === 0) { return (

데이터가 없습니다

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

); } // 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인) const hasActiveFields = fields.some( (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) ); if (!hasActiveFields) { return (
{/* 필드 패널 */} setShowFieldPanel(!showFieldPanel)} /> {/* 안내 메시지 */}

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

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

{/* 필드 선택기 모달 */}
); } // 피벗 결과 없음 if (!pivotResult) { return (
); } const { flatColumns, dataMatrix, grandTotals } = pivotResult; // ==================== 키보드 네비게이션 ==================== // 키보드 핸들러 const handleKeyDown = (e: React.KeyboardEvent) => { if (!selectedCell) return; const { rowIndex, colIndex } = selectedCell; const maxRowIndex = visibleFlatRows.length - 1; const maxColIndex = flatColumns.length - 1; let newRowIndex = rowIndex; let newColIndex = colIndex; switch (e.key) { case "ArrowUp": e.preventDefault(); newRowIndex = Math.max(0, rowIndex - 1); break; case "ArrowDown": e.preventDefault(); newRowIndex = Math.min(maxRowIndex, rowIndex + 1); break; case "ArrowLeft": e.preventDefault(); newColIndex = Math.max(0, colIndex - 1); break; case "ArrowRight": e.preventDefault(); newColIndex = Math.min(maxColIndex, colIndex + 1); break; case "Home": e.preventDefault(); if (e.ctrlKey) { newRowIndex = 0; newColIndex = 0; } else { newColIndex = 0; } break; case "End": e.preventDefault(); if (e.ctrlKey) { newRowIndex = maxRowIndex; newColIndex = maxColIndex; } else { newColIndex = maxColIndex; } break; case "PageUp": e.preventDefault(); newRowIndex = Math.max(0, rowIndex - 10); break; case "PageDown": e.preventDefault(); newRowIndex = Math.min(maxRowIndex, rowIndex + 10); break; case "Enter": e.preventDefault(); // 셀 더블클릭과 동일한 동작 (드릴다운) if (visibleFlatRows[rowIndex] && flatColumns[colIndex]) { const row = visibleFlatRows[rowIndex]; const col = flatColumns[colIndex]; const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; const values = dataMatrix.get(cellKey) || []; // 드릴다운 모달 열기 const cellData: PivotCellData = { value: values[0]?.value, rowPath: row.path, columnPath: col.path, field: values[0]?.field, }; setDrillDownData({ open: true, cellData }); } break; case "Escape": e.preventDefault(); setSelectedCell(null); setSelectionRange(null); break; case "c": // Ctrl+C: 클립보드 복사 if (e.ctrlKey || e.metaKey) { e.preventDefault(); copySelectionToClipboard(); } return; case "a": // Ctrl+A: 전체 선택 if (e.ctrlKey || e.metaKey) { e.preventDefault(); setSelectionRange({ startRow: 0, startCol: 0, endRow: visibleFlatRows.length - 1, endCol: flatColumns.length - 1, }); } return; default: return; } if (newRowIndex !== rowIndex || newColIndex !== colIndex) { setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); } }; // 셀 클릭으로 선택 (Shift+클릭으로 범위 선택) const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => { if (shiftKey && selectedCell) { // Shift+클릭: 범위 선택 setSelectionRange({ startRow: Math.min(selectedCell.rowIndex, rowIndex), startCol: Math.min(selectedCell.colIndex, colIndex), endRow: Math.max(selectedCell.rowIndex, rowIndex), endCol: Math.max(selectedCell.colIndex, colIndex), }); } else { // 일반 클릭: 단일 선택 setSelectedCell({ rowIndex, colIndex }); setSelectionRange(null); } }; // 셀이 선택 범위 내에 있는지 확인 const isCellInRange = (rowIndex: number, colIndex: number): boolean => { if (selectionRange) { return ( rowIndex >= selectionRange.startRow && rowIndex <= selectionRange.endRow && colIndex >= selectionRange.startCol && colIndex <= selectionRange.endCol ); } if (selectedCell) { return selectedCell.rowIndex === rowIndex && selectedCell.colIndex === colIndex; } return false; }; // 열 크기 조절 시작 const handleResizeStart = (colIdx: number, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setResizingColumn(colIdx); setResizeStartX(e.clientX); setResizeStartWidth(columnWidths[colIdx] || 100); }; // 클립보드에 선택 영역 복사 const copySelectionToClipboard = () => { const range = selectionRange || (selectedCell ? { startRow: selectedCell.rowIndex, startCol: selectedCell.colIndex, endRow: selectedCell.rowIndex, endCol: selectedCell.colIndex, } : null); if (!range) return; const lines: string[] = []; for (let rowIdx = range.startRow; rowIdx <= range.endRow; rowIdx++) { const row = visibleFlatRows[rowIdx]; if (!row) continue; const rowValues: string[] = []; for (let colIdx = range.startCol; colIdx <= range.endCol; colIdx++) { const col = flatColumns[colIdx]; if (!col) continue; const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; const values = dataMatrix.get(cellKey) || []; const cellValue = values.map((v) => v.formattedValue || v.value || "").join(", "); rowValues.push(cellValue); } lines.push(rowValues.join("\t")); } const text = lines.join("\n"); navigator.clipboard.writeText(text).then(() => { // 복사 성공 피드백 (선택적) console.log("클립보드에 복사됨:", text); }).catch((err) => { console.error("클립보드 복사 실패:", err); }); }; // 정렬 토글 const handleSort = (field: string) => { setSortConfig((prev) => { if (prev?.field === field) { // 같은 필드 클릭: asc -> desc -> null 순환 if (prev.direction === "asc") { return { field, direction: "desc" }; } return null; // 정렬 해제 } // 새로운 필드: asc로 시작 return { field, direction: "asc" }; }); }; // 정렬 아이콘 렌더링 const SortIcon = ({ field }: { field: string }) => { if (sortConfig?.field !== field) { return ; } if (sortConfig.direction === "asc") { return ; } return ; }; return (
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} setShowFieldPanel(!showFieldPanel)} /> {/* 헤더 툴바 */}
{title &&

{title}

} ({filteredData.length !== data.length ? `${filteredData.length} / ${data.length}건` : `${data.length}건`})
{/* 필드 선택기 버튼 */} {fieldChooser?.enabled !== false && ( )} {/* 필드 패널 토글 */} {allowExpandAll && ( <> )} {/* 차트 토글 */} {chartConfig && ( )} {/* 내보내기 버튼들 */} {exportConfig?.excel && ( <> )} {/* 숨겨진 필드 표시 드롭다운 */} {hiddenFieldsList.length > 0 && (
숨겨진 필드
{hiddenFieldsList.map((field) => ( ))}
)}
{/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */} {filterFields.length > 0 && (
필터:
{filterFields.map((filterField) => { const selectedValues = filterField.filterValues || []; const isFiltered = selectedValues.length > 0; return ( { const newFields = fields.map((f) => f.field === field.field && f.area === field.area ? { ...f, filterValues: values, filterType: type } : f ); handleFieldsChange(newFields); }} trigger={ } /> ); })}
)} {/* 피벗 테이블 */}
0 ? containerHeight : undefined, minHeight: 100 // 최소 높이 보장 - 블라인드 효과 방지 }} tabIndex={0} onKeyDown={handleKeyDown} > {/* 열 헤더 */} {/* 좌상단 코너 (행 필드 라벨 + 필터) */} {/* 열 헤더 셀 */} {flatColumns.map((col, idx) => ( ))} {/* 행 총계 헤더 */} {totals?.showRowGrandTotals && ( )} {/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */} {columnFields.length > 0 && ( )} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {dataFields.length > 1 && ( {flatColumns.map((col, colIdx) => ( {dataFields.map((df, dfIdx) => ( ))} ))} )} {/* 열 총계 행 (상단 위치) */} {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( {flatColumns.map((col, colIdx) => ( ))} {/* 대총합 */} {totals?.showRowGrandTotals && ( )} )} {/* 가상 스크롤 상단 여백 */} {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( )} {(() => { // 셀 병합 정보 계산 const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false); return visibleFlatRows.map((row, idx) => { // 실제 행 인덱스 계산 const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false }; return ( {/* 행 헤더 (병합되면 skip) */} {!cellMerge.skip && ( )} {/* 데이터 셀 */} {flatColumns.map((col, colIdx) => { const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; const values = dataMatrix.get(cellKey) || []; // 조건부 서식 (첫 번째 값 기준) const conditionalStyle = values.length > 0 && values[0].field ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) : undefined; // 선택 상태 확인 (범위 선택 포함) const isCellSelected = isCellInRange(rowIdx, colIdx); return ( { handleCellSelect(rowIdx, colIdx, e?.shiftKey || false); if (onCellClick) { handleCellClick(row.path, col.path, values); } }} onDoubleClick={() => handleCellDoubleClick(row.path, col.path, values) } /> ); })} {/* 행 총계 */} {totals?.showRowGrandTotals && ( )} ); }); })()} {/* 가상 스크롤 하단 여백 - 음수 방지 */} {enableVirtualScroll && (() => { const bottomPadding = Math.max(0, virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT)); return bottomPadding > 0 ? ( ) : null; })()} {/* 열 총계 행 (하단 위치 - 기본값) */} {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && ( {flatColumns.map((col, colIdx) => ( ))} {/* 대총합 */} {totals?.showRowGrandTotals && ( )} )}
0 ? 2 : 1} >
{rowFields.map((f, idx) => (
{f.caption} { const newFields = fields.map((fld) => fld.field === field.field && fld.area === "row" ? { ...fld, filterValues: values, filterType: type } : fld ); handleFieldsChange(newFields); }} trigger={ } /> {idx < rowFields.length - 1 && /}
))} {rowFields.length === 0 && 항목}
handleSort(dataFields[0].field) : undefined} >
{col.caption || "(전체)"} {dataFields.length === 1 && }
{/* 열 리사이즈 핸들 */}
handleResizeStart(idx, e)} />
1 ? 2 : 1} > 총계 1 ? 2 : 1} >
{columnFields.map((f) => ( { const newFields = fields.map((fld) => fld.field === field.field && fld.area === "column" ? { ...fld, filterValues: values, filterType: type } : fld ); handleFieldsChange(newFields); }} trigger={ } /> ))}
handleSort(df.field)} >
{df.caption}
총계
총계
{/* 차트 */} {showChart && chartConfig && pivotResult && ( )} {/* 필드 선택기 모달 */} {/* Drill Down 모달 */} setDrillDownData((prev) => ({ ...prev, open }))} cellData={drillDownData.cellData} data={data} fields={fields} rowFields={rowFields} columnFields={columnFields} />
); }; export default PivotGridComponent;