From 63a47537014b5a47464fb0a25de0375a1a648eaf Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 17:10:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=84=A0=ED=83=9D=EB=B0=95=EC=8A=A4=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EC=97=88=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 445 ++++++++++++------ .../registry/components/pivot-grid/types.ts | 13 +- .../pivot-grid/utils/pivotEngine.ts | 104 +++- 3 files changed, 413 insertions(+), 149 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index b815ce06..8db463c4 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 && ( @@ -1689,137 +1763,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/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index d55d6982..d4d8b1e5 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -331,8 +331,11 @@ export interface PivotResult { // 플랫 행 목록 (렌더링용) flatRows: PivotFlatRow[]; - // 플랫 열 목록 (렌더링용) + // 플랫 열 목록 (렌더링용) - 리프 노드만 flatColumns: PivotFlatColumn[]; + + // 열 헤더 레벨별 (다중 행 헤더용) + columnHeaderLevels: PivotColumnHeaderCell[][]; // 총합계 grandTotals: { @@ -361,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 9c3b5bc1..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; +} + /** * 키를 경로로 변환 */ @@ -326,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; +} + // ==================== 데이터 매트릭스 생성 ==================== /** @@ -735,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)); } // 헤더 트리 생성 @@ -788,6 +873,12 @@ export function processPivotData( grandTotals.grand ); + // 다중 행 열 헤더 생성 + const columnHeaderLevels = buildColumnHeaderLevels( + columnHeaders, + columnFields.length + ); + return { rowHeaders, columnHeaders, @@ -799,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} + > + 총계 +