"use client"; /** * PivotGrid 메인 컴포넌트 * 다차원 데이터 분석을 위한 피벗 테이블 */ import React, { useState, useMemo, useCallback, useEffect } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, PivotResult, PivotFieldConfig, PivotCellData, PivotFlatRow, PivotCellValue, PivotGridState, PivotAreaType, } 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 { ChevronRight, ChevronDown, Download, Settings, RefreshCw, Maximize2, Minimize2, LayoutGrid, FileSpreadsheet, BarChart3, } 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; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; } const DataCell: React.FC = ({ values, isTotal = false, onClick, onDoubleClick, conditionalStyle, }) => { // 조건부 서식 스타일 계산 const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; const icon = conditionalStyle?.icon; if (!values || values.length === 0) { return ( - ); } // 단일 데이터 필드인 경우 if (values.length === 1) { return ( {/* Data Bar */} {hasDataBar && (
)} {icon && {icon}} {values[0].formattedValue} ); } // 다중 데이터 필드인 경우 return ( <> {values.map((val, idx) => ( {hasDataBar && (
)} {icon && {icon}} {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, }) => { // 디버깅 로그 console.log("🔶 PivotGridComponent props:", { title, hasExternalData: !!externalData, externalDataLength: externalData?.length, initialFieldsLength: initialFields?.length, }); // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); const [isFullscreen, setIsFullscreen] = useState(false); const [showFieldPanel, setShowFieldPanel] = useState(true); 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); // 외부 fields 변경 시 동기화 useEffect(() => { if (initialFields.length > 0) { setFields(initialFields); } }, [initialFields]); // 데이터 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( () => fields .filter((f) => f.area === "filter" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), [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 pivotResult = useMemo(() => { if (!data || data.length === 0 || fields.length === 0) { return null; } const visibleFields = fields.filter((f) => f.visible !== false); if (visibleFields.filter((f) => f.area !== "filter").length === 0) { return null; } return processPivotData( data, visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // 조건부 서식용 전체 값 수집 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 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(() => { 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] ); // 셀 더블클릭 (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]); // ==================== 렌더링 ==================== // 빈 상태 if (!data || data.length === 0) { return (

데이터가 없습니다

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

); } // 필드 미설정 const hasActiveFields = fields.some( (f) => f.visible !== false && f.area !== "filter" ); if (!hasActiveFields) { return (
{/* 필드 패널 */} setShowFieldPanel(!showFieldPanel)} /> {/* 안내 메시지 */}

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

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

{/* 필드 선택기 모달 */}
); } // 피벗 결과 없음 if (!pivotResult) { return (
); } const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; return (
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} setShowFieldPanel(!showFieldPanel)} /> {/* 헤더 툴바 */}
{title &&

{title}

} ({data.length}건)
{/* 필드 선택기 버튼 */} {fieldChooser?.enabled !== false && ( )} {/* 필드 패널 토글 */} {allowExpandAll && ( <> )} {/* 차트 토글 */} {chartConfig && ( )} {/* 내보내기 버튼들 */} {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) || []; // 조건부 서식 (첫 번째 값 기준) const conditionalStyle = values.length > 0 && values[0].field ? getCellConditionalStyle(values[0].value, values[0].field) : undefined; return ( handleCellClick(row.path, col.path, values) : undefined } onDoubleClick={() => handleCellDoubleClick(row.path, col.path, values) } /> ); })} {/* 행 총계 */} {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}
총계
{/* 차트 */} {showChart && chartConfig && pivotResult && ( )} {/* 필드 선택기 모달 */} {/* Drill Down 모달 */} setDrillDownData((prev) => ({ ...prev, open }))} cellData={drillDownData.cellData} data={data} fields={fields} rowFields={rowFields} columnFields={columnFields} />
); }; export default PivotGridComponent;