diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 9135231c..feda2167 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -296,24 +296,6 @@ export const PivotGridComponent: React.FC = ({ onFieldDrop, onExpandChange, }) => { - // 디버깅 로그 - console.log("🔶 PivotGridComponent props:", { - title, - hasExternalData: !!externalData, - externalDataLength: externalData?.length, - initialFieldsLength: initialFields?.length, - }); - - // 🆕 데이터 샘플 확인 - if (externalData && externalData.length > 0) { - console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); - console.log("🔶 전체 데이터 개수:", externalData.length); - } - - // 🆕 필드 설정 확인 - if (initialFields && initialFields.length > 0) { - console.log("🔶 필드 설정:", initialFields); - } // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); @@ -406,10 +388,31 @@ export const PivotGridComponent: React.FC = ({ } } - // 나머지 상태 복원 - if (parsed.pivotState) setPivotState(parsed.pivotState); + // 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) setColumnWidths(parsed.columnWidths); + if (parsed.columnWidths && typeof parsed.columnWidths === "object") { + setColumnWidths(parsed.columnWidths); + } } catch (e) { console.warn("피벗 상태 복원 실패, localStorage 초기화:", e); // 손상된 상태는 제거 @@ -452,14 +455,6 @@ export const PivotGridComponent: React.FC = ({ const result = fields .filter((f) => f.area === "filter" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - console.log("🔷 [filterFields] 필터 필드 계산:", { - totalFields: fields.length, - filterFieldsCount: result.length, - filterFieldNames: result.map(f => f.field), - allFieldAreas: fields.map(f => ({ field: f.field, area: f.area, visible: f.visible })), - }); - return result; }, [fields] @@ -524,51 +519,54 @@ export const PivotGridComponent: React.FC = ({ // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { - if (!filteredData || filteredData.length === 0 || fields.length === 0) { + 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; } - - // 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 - ); - - // 🆕 피벗 결과 확인 - console.log("🔶 피벗 처리 결과:", { - hasResult: !!result, - flatRowsCount: result?.flatRows?.length, - flatColumnsCount: result?.flatColumns?.length, - dataMatrixSize: result?.dataMatrix?.size, - expandedRowPaths: pivotState.expandedRowPaths.length, - expandedColumnPaths: pivotState.expandedColumnPaths.length, - }); - - return result; }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // 초기 로드 시 첫 레벨 자동 확장 useEffect(() => { - if (pivotResult && pivotResult.flatRows.length > 0 && !isInitialExpanded) { - // 첫 레벨 행들의 경로 수집 (level 0인 행들) - const firstLevelRows = pivotResult.flatRows.filter((row) => row.level === 0 && row.hasChildren); + 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) { - const firstLevelPaths = firstLevelRows.map((row) => row.path); - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: firstLevelPaths, - })); - setIsInitialExpanded(true); + // 첫 레벨 행이 있으면 자동 확장 + 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]); @@ -727,15 +725,6 @@ export const PivotGridComponent: React.FC = ({ // 필드 변경 const handleFieldsChange = useCallback( (newFields: PivotFieldConfig[]) => { - // FieldChooser에서 이미 필드를 완전히 제거하므로 추가 필터링 불필요 - console.log("🔷 [handleFieldsChange] 필드 변경:", { - totalFields: newFields.length, - filterFields: newFields.filter(f => f.area === "filter").length, - filterFieldNames: newFields.filter(f => f.area === "filter").map(f => f.field), - rowFields: newFields.filter(f => f.area === "row").length, - columnFields: newFields.filter(f => f.area === "column").length, - dataFields: newFields.filter(f => f.area === "data").length, - }); setFields(newFields); }, [] @@ -744,8 +733,6 @@ export const PivotGridComponent: React.FC = ({ // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { - console.log("🔶 행 확장/축소 클릭:", path); - setPivotState((prev) => { const pathKey = pathToKey(path); const existingIndex = prev.expandedRowPaths.findIndex( @@ -754,16 +741,13 @@ export const PivotGridComponent: React.FC = ({ let newPaths: string[][]; if (existingIndex >= 0) { - console.log("🔶 행 축소:", path); newPaths = prev.expandedRowPaths.filter( (_, i) => i !== existingIndex ); } else { - console.log("🔶 행 확장:", path); newPaths = [...prev.expandedRowPaths, path]; } - console.log("🔶 새로운 확장 경로:", newPaths); onExpandChange?.(newPaths); return { @@ -777,59 +761,58 @@ export const PivotGridComponent: React.FC = ({ // 전체 확장 (재귀적으로 모든 레벨 확장) const handleExpandAll = useCallback(() => { - if (!pivotResult) { - console.log("❌ [handleExpandAll] pivotResult가 없음"); - return; - } - - // 🆕 재귀적으로 모든 가능한 경로 생성 - const allRowPaths: string[][] = []; - const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false); - - // 데이터에서 모든 고유한 경로 추출 - 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); + try { + if (!pivotResult) { + return; } - }); - // Set을 배열로 변환 - pathSet.forEach((pathKey) => { - allRowPaths.push(JSON.parse(pathKey)); - }); + // 재귀적으로 모든 가능한 경로 생성 + 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); + } + }); - console.log("🔷 [handleExpandAll] 확장할 행:", { - totalRows: pivotResult.flatRows.length, - rowsWithChildren: allRowPaths.length, - paths: allRowPaths.slice(0, 5), // 처음 5개만 로그 - }); + // 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: [], - })); + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + } catch (error) { + console.error("❌ [handleExpandAll] 에러:", error); + } }, [pivotResult, fields, filteredData]); // 전체 축소 const handleCollapseAll = useCallback(() => { - console.log("🔷 [handleCollapseAll] 전체 축소 실행"); - - setPivotState((prev) => { - console.log("🔷 [handleCollapseAll] 이전 상태:", { - expandedRowPaths: prev.expandedRowPaths.length, - expandedColumnPaths: prev.expandedColumnPaths.length, - }); - - return { - ...prev, - expandedRowPaths: [], - expandedColumnPaths: [], - }; - }); + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); }, []); // 셀 클릭 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 191f3610..61ebacd7 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; @@ -8,6 +8,66 @@ import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; import { PivotFieldConfig } from "./types"; import { dataApi } from "@/lib/api/data"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== 에러 경계 ==================== + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class PivotGridErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + ErrorBoundaryState +> { + constructor(props: { children: ReactNode; onReset?: () => void }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("🔴 [PivotGrid] 렌더링 에러:", error); + console.error("🔴 [PivotGrid] 에러 정보:", errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }); + this.props.onReset?.(); + }; + + render() { + if (this.state.hasError) { + return ( +
+ +

+ 피벗 그리드 오류 +

+

+ {this.state.error?.message || "알 수 없는 오류가 발생했습니다."} +

+ +
+ ); + } + + return this.props.children; + } +} // ==================== 샘플 데이터 (미리보기용) ==================== @@ -111,19 +171,14 @@ const PivotGridWrapper: React.FC = (props) => { setIsLoading(true); try { - console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName); - const response = await dataApi.getTableData(tableName, { page: 1, - size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size) + size: 10000, // 피벗 분석용 대량 데이터 }); - console.log("🔷 [PivotGrid] API 응답:", response); - // dataApi.getTableData는 { data, total, page, size, totalPages } 구조 if (response.data && Array.isArray(response.data)) { setLoadedData(response.data); - console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건"); } else { console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음"); setLoadedData([]); @@ -137,21 +192,6 @@ const PivotGridWrapper: React.FC = (props) => { loadTableData(); }, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]); - - // 디버깅 로그 - console.log("🔷 PivotGridWrapper props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasLoadedData: loadedData.length > 0, - loadedDataLength: loadedData.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - isLoading, - }); // 디자인 모드 판단: // 1. isDesignMode === true @@ -173,13 +213,6 @@ const PivotGridWrapper: React.FC = (props) => { ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" : (componentConfig.title || props.title); - console.log("🔷 PivotGridWrapper final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - // 총계 설정 const totalsConfig = componentConfig.totals || props.totals || { showRowGrandTotals: true, @@ -200,24 +233,27 @@ const PivotGridWrapper: React.FC = (props) => { ); } + // 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함 return ( - + + + ); }; @@ -283,18 +319,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { const componentConfig = props.componentConfig || props.config || {}; const configFields = componentConfig.fields || props.fields; const configData = props.data; - - // 디버깅 로그 - console.log("🔷 PivotGridRenderer props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - }); // 디자인 모드 판단: // 1. isDesignMode === true @@ -314,13 +338,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" : (componentConfig.title || props.title); - console.log("🔷 PivotGridRenderer final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - // 총계 설정 const totalsConfig = componentConfig.totals || props.totals || { showRowGrandTotals: true, diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index a948aba0..fba64e65 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -267,13 +267,9 @@ export const FieldChooser: React.FC = ({ const existingConfig = selectedFields.find((f) => f.field === field.field); if (area === "none") { - // 🆕 필드 완전 제거 (visible: false 대신 배열에서 제거) + // 필드 완전 제거 (visible: false 대신 배열에서 제거) if (existingConfig) { const newFields = selectedFields.filter((f) => f.field !== field.field); - console.log("🔷 [FieldChooser] 필드 제거:", { - removedField: field.field, - remainingFields: newFields.length, - }); onFieldsChange(newFields); } } else { @@ -284,10 +280,6 @@ export const FieldChooser: React.FC = ({ ? { ...f, area, visible: true } : f ); - console.log("🔷 [FieldChooser] 필드 영역 변경:", { - field: field.field, - newArea: area, - }); onFieldsChange(newFields); } else { // 새 필드 추가 @@ -300,10 +292,6 @@ export const FieldChooser: React.FC = ({ summaryType: area === "data" ? "sum" : undefined, areaIndex: selectedFields.filter((f) => f.area === area).length, }; - console.log("🔷 [FieldChooser] 필드 추가:", { - field: field.field, - area, - }); onFieldsChange([...selectedFields, newField]); } } diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 08dca70e..967afd08 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -360,7 +360,6 @@ export const FieldPanel: React.FC = ({ // 1. overId가 영역 자체인 경우 (filter, column, row, data) if (["filter", "column", "row", "data"].includes(overId)) { setOverArea(overId as PivotAreaType); - console.log("🔷 [handleDragOver] 영역 감지:", overId); return; } @@ -368,7 +367,6 @@ export const FieldPanel: React.FC = ({ const targetArea = overId.split("-")[0] as PivotAreaType; if (["filter", "column", "row", "data"].includes(targetArea)) { setOverArea(targetArea); - console.log("🔷 [handleDragOver] 필드 영역 감지:", targetArea); } }; @@ -380,19 +378,12 @@ export const FieldPanel: React.FC = ({ setOverArea(null); if (!over) { - console.log("🔷 [FieldPanel] 드롭 대상 없음"); return; } const activeId = active.id as string; const overId = over.id as string; - console.log("🔷 [FieldPanel] 드래그 종료:", { - activeId, - overId, - detectedOverArea: currentOverArea, - }); - // 필드 정보 파싱 const [sourceArea, sourceField] = activeId.split("-") as [ PivotAreaType, @@ -409,13 +400,6 @@ export const FieldPanel: React.FC = ({ targetArea = overId.split("-")[0] as PivotAreaType; } - console.log("🔷 [FieldPanel] 파싱 결과:", { - sourceArea, - sourceField, - targetArea, - usedOverArea: !!currentOverArea, - }); - // 같은 영역 내 정렬 if (sourceArea === targetArea) { const areaFields = fields.filter((f) => f.area === sourceArea); @@ -447,12 +431,6 @@ export const FieldPanel: React.FC = ({ // 다른 영역으로 이동 if (["filter", "column", "row", "data"].includes(targetArea)) { - console.log("🔷 [FieldPanel] 영역 이동:", { - field: sourceField, - from: sourceArea, - to: targetArea, - }); - const newFields = fields.map((f) => { if (f.field === sourceField && f.area === sourceArea) { return { @@ -464,12 +442,6 @@ export const FieldPanel: React.FC = ({ return f; }); - console.log("🔷 [FieldPanel] 변경된 필드:", { - totalFields: newFields.length, - filterFields: newFields.filter(f => f.area === "filter").length, - changedField: newFields.find(f => f.field === sourceField), - }); - onFieldsChange(newFields); } }; diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 4d3fecfd..702a13e5 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -728,9 +728,15 @@ export function processPivotData( } } - // 확장 경로 Set 변환 - const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); - const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + // 확장 경로 Set 변환 (잘못된 형식 필터링) + const validRowPaths = (expandedRowPaths || []).filter( + (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") + ); + const validColPaths = (expandedColumnPaths || []).filter( + (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") + ); + const expandedRowSet = new Set(validRowPaths.map(pathToKey)); + const expandedColSet = new Set(validColPaths.map(pathToKey)); // 기본 확장: 첫 번째 레벨 모두 확장 if (expandedRowPaths.length === 0 && rowFields.length > 0) {