diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 53ad204d..13cb1a68 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -7,6 +7,8 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; import { PivotGridProps, PivotResult, @@ -50,6 +52,10 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; +// ==================== 상수 ==================== + +const PIVOT_STATE_VERSION = "1.0"; // 상태 저장 버전 (호환성 체크용) + // ==================== 유틸리티 함수 ==================== // 셀 병합 정보 계산 @@ -128,7 +134,10 @@ const RowHeaderCell: React.FC = ({
{row.hasChildren && ( )} + + {/* 상태 유지 체크박스 */} +
+ setPersistState(checked === true)} + className="h-3.5 w-3.5" + /> + +
{/* 차트 토글 */} {chartConfig && ( @@ -1685,137 +1782,224 @@ export const PivotGridComponent: React.FC = ({ > - {/* 열 헤더 */} - - {/* 좌상단 코너 (행 필드 라벨 + 필터) */} - + {/* 좌상단 코너 (첫 번째 레벨에만 표시) */} + {levelIdx === 0 && ( + + )} + + {/* 열 헤더 셀 - 해당 레벨 */} + {levelCells.map((cell, cellIdx) => ( + ))} - {rowFields.length === 0 && 항목} - - - {/* 열 헤더 셀 */} - {flatColumns.map((col, idx) => ( - )} - colSpan={dataFields.length || 1} - style={{ width: columnWidths[idx] || "auto", minWidth: 50 }} - onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} - > -
- {col.caption || "(전체)"} - {dataFields.length === 1 && } -
- {/* 열 리사이즈 핸들 */} -
handleResizeStart(idx, e)} - /> - - ))} - - {/* 행 총계 헤더 */} - {totals?.showRowGrandTotals && ( -
)} - colSpan={dataFields.length || 1} - rowSpan={dataFields.length > 1 ? 2 : 1} - > - 총계 - - )} - - {/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */} - {columnFields.length > 0 && ( + + )) + ) : ( + // 열 필드가 없는 경우: 단일 행 + - )} - + + {/* 열 헤더 셀 (열 필드 없을 때) */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + )} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {dataFields.length > 1 && ( diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index 37f0862b..448c92a5 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -16,6 +16,7 @@ import { PivotAreaType, AggregationType, FieldDataType, + DateGroupInterval, } from "./types"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -202,6 +203,28 @@ const AreaDropZone: React.FC = ({ )} + {/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */} + {(area === "row" || area === "column") && field.dataType === "date" && ( + + )} + + {/* 필터 아이콘 (필터 적용 시) */} + {hasFilter && ( + + )} + {/* 필드 라벨 */} + + + + + 필터만 초기화 + {filteredFieldCount > 0 && ( + + ({filteredFieldCount}개) + + )} + + + + 필드 배치 초기화 + + + + + 전체 초기화 + + + + + {/* 접기 버튼 */} + {onToggleCollapse && ( - - )} + )} + {/* 드래그 오버레이 */} diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index 87ba2414..d4d8b1e5 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -304,6 +304,7 @@ export interface PivotHeaderNode { level: number; // 깊이 children?: PivotHeaderNode[]; // 자식 노드 isExpanded: boolean; // 확장 상태 + hasChildren: boolean; // 자식 존재 가능 여부 (다음 레벨 필드 있음) path: string[]; // 경로 (드릴다운용) subtotal?: PivotCellValue[]; // 소계 span?: number; // colspan/rowspan @@ -330,8 +331,11 @@ export interface PivotResult { // 플랫 행 목록 (렌더링용) flatRows: PivotFlatRow[]; - // 플랫 열 목록 (렌더링용) + // 플랫 열 목록 (렌더링용) - 리프 노드만 flatColumns: PivotFlatColumn[]; + + // 열 헤더 레벨별 (다중 행 헤더용) + columnHeaderLevels: PivotColumnHeaderCell[][]; // 총합계 grandTotals: { @@ -360,6 +364,14 @@ export interface PivotFlatColumn { isTotal?: boolean; } +// 열 헤더 셀 (다중 행 헤더용) +export interface PivotColumnHeaderCell { + caption: string; // 표시 텍스트 + colSpan: number; // 병합할 열 수 + path: string[]; // 전체 경로 + level: number; // 레벨 (0부터 시작) +} + // ==================== 상태 관리 ==================== export interface PivotGridState { diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 02dd4608..35893dea 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -10,6 +10,7 @@ import { PivotFlatRow, PivotFlatColumn, PivotCellValue, + PivotColumnHeaderCell, DateGroupInterval, AggregationType, SummaryDisplayMode, @@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string { return path.join("||"); } +/** + * 모든 가능한 경로 생성 (열 전체 확장용) + */ +function generateAllPaths( + data: Record[], + fields: PivotFieldConfig[] +): string[] { + const allPaths: string[] = []; + + // 각 레벨까지의 고유 경로 수집 + for (let depth = 1; depth <= fields.length; depth++) { + const fieldsAtDepth = fields.slice(0, depth); + const pathSet = new Set(); + + data.forEach((row) => { + const path = fieldsAtDepth.map((f) => getFieldValue(row, f)); + pathSet.add(pathToKey(path)); + }); + + pathSet.forEach((pathKey) => allPaths.push(pathKey)); + } + + return allPaths; +} + /** * 키를 경로로 변환 */ @@ -129,6 +155,7 @@ function buildHeaderTree( caption: key, level: 0, isExpanded: expandedPaths.has(pathKey), + hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음 path: path, span: 1, }; @@ -195,6 +222,7 @@ function buildChildNodes( caption: key, level: level, isExpanded: expandedPaths.has(pathKey), + hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음 path: path, span: 1, }; @@ -238,7 +266,7 @@ function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { level: node.level, caption: node.caption, isExpanded: node.isExpanded, - hasChildren: !!(node.children && node.children.length > 0), + hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준) }); if (node.isExpanded && node.children) { @@ -324,6 +352,66 @@ function getMaxColumnLevel( return Math.min(maxLevel, totalFields - 1); } +/** + * 다중 행 열 헤더 생성 + * 각 레벨별로 셀과 colSpan 정보를 반환 + */ +function buildColumnHeaderLevels( + nodes: PivotHeaderNode[], + totalLevels: number +): PivotColumnHeaderCell[][] { + if (totalLevels === 0 || nodes.length === 0) { + return []; + } + + const levels: PivotColumnHeaderCell[][] = Array.from( + { length: totalLevels }, + () => [] + ); + + // 리프 노드 수 계산 (colSpan 계산용) + function countLeaves(node: PivotHeaderNode): number { + if (!node.children || node.children.length === 0 || !node.isExpanded) { + return 1; + } + return node.children.reduce((sum, child) => sum + countLeaves(child), 0); + } + + // 트리 순회하며 각 레벨에 셀 추가 + function traverse(node: PivotHeaderNode, level: number) { + const colSpan = countLeaves(node); + + levels[level].push({ + caption: node.caption, + colSpan, + path: node.path, + level, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } else if (level < totalLevels - 1) { + // 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움 + for (let i = level + 1; i < totalLevels; i++) { + levels[i].push({ + caption: "", + colSpan, + path: node.path, + level: i, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + // ==================== 데이터 매트릭스 생성 ==================== /** @@ -733,12 +821,11 @@ export function processPivotData( uniqueValues.forEach((val) => expandedRowSet.add(val)); } - if (expandedColumnPaths.length === 0 && columnFields.length > 0) { - const firstField = columnFields[0]; - const uniqueValues = new Set( - filteredData.map((row) => getFieldValue(row, firstField)) - ); - uniqueValues.forEach((val) => expandedColSet.add(val)); + // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음) + // 모든 가능한 열 경로를 확장 상태로 설정 + if (columnFields.length > 0) { + const allColumnPaths = generateAllPaths(filteredData, columnFields); + allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey)); } // 헤더 트리 생성 @@ -786,6 +873,12 @@ export function processPivotData( grandTotals.grand ); + // 다중 행 열 헤더 생성 + const columnHeaderLevels = buildColumnHeaderLevels( + columnHeaders, + columnFields.length + ); + return { rowHeaders, columnHeaders, @@ -797,6 +890,7 @@ export function processPivotData( caption: path[path.length - 1] || "", span: 1, })), + columnHeaderLevels, grandTotals, }; }
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 && /} -
+ {/* 다중 행 열 헤더 */} + {columnHeaderLevels.length > 0 ? ( + // 열 필드가 있는 경우: 각 레벨별로 행 생성 + columnHeaderLevels.map((levelCells, levelIdx) => ( +
1 ? 1 : 0)} + > +
+ {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 && 항목} +
+
+
+ {cell.caption || "(전체)"} + {levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && ( + + )} +
+
1 ? 1 : 0)} + > + 총계 + 0 && ( + 1 ? 1 : 0)} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
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={ - - } - /> +
+ {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} + > + 총계 +