From cb8b434434a957da3ec78f889712aed7671091c4 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 16:01:58 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=ED=94=BC=EB=B2=97=EC=97=90=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=95=88=EB=90=98=EB=8D=98=EA=B1=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/registry/components/pivot-grid/PivotGridComponent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 53ad204d..4b4465e1 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -500,9 +500,9 @@ export const PivotGridComponent: React.FC = ({ const filteredData = useMemo(() => { if (!data || data.length === 0) return data; - // 필터 영역의 필드들로 데이터 필터링 + // 모든 영역(행/열/필터)의 필터 값이 있는 필드로 데이터 필터링 const activeFilters = fields.filter( - (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 + (f) => f.filterValues && f.filterValues.length > 0 ); if (activeFilters.length === 0) return data; -- 2.43.0 From e6bb366ec7c04d7a88b44cb8fc856459bbbd5fa1 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 16:15:20 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=ED=94=BC=EB=B2=97=EC=97=90=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=EC=AA=BD=EC=97=90=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=B2=84=ED=8A=BC=20=EB=84=A3=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 | 4 + .../pivot-grid/components/FieldPanel.tsx | 162 +++++++++++++++++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 4b4465e1..b815ce06 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -299,6 +299,8 @@ export const PivotGridComponent: React.FC = ({ // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); + // 초기 필드 설정 저장 (초기화용) + const initialFieldsRef = useRef(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], @@ -1129,6 +1131,7 @@ export const PivotGridComponent: React.FC = ({ onFieldsChange={handleFieldsChange} collapsed={!showFieldPanel} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} + initialFields={initialFieldsRef.current} /> {/* 안내 메시지 */} @@ -1405,6 +1408,7 @@ export const PivotGridComponent: React.FC = ({ onFieldsChange={handleFieldsChange} collapsed={!showFieldPanel} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} + initialFields={initialFieldsRef.current} /> {/* 헤더 툴바 */} diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 967afd08..2ef1227e 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -37,6 +37,10 @@ import { BarChart3, GripVertical, ChevronDown, + RotateCcw, + FilterX, + LayoutGrid, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -56,6 +60,8 @@ interface FieldPanelProps { onFieldSettingsChange?: (field: PivotFieldConfig) => void; collapsed?: boolean; onToggleCollapse?: () => void; + /** 초기 필드 설정 (필드 배치 초기화용) */ + initialFields?: PivotFieldConfig[]; } interface FieldChipProps { @@ -123,15 +129,23 @@ const SortableFieldChip: React.FC = ({ transition, }; + // 필터 적용 여부 확인 + const hasFilter = field.filterValues && field.filterValues.length > 0; + const filterCount = field.filterValues?.length || 0; + return (
{/* 드래그 핸들 */} @@ -143,11 +157,24 @@ const SortableFieldChip: React.FC = ({ + {/* 필터 아이콘 (필터 적용 시) */} + {hasFilter && ( + + )} + {/* 필드 라벨 */}
- {/* 접기 버튼 */} - {onToggleCollapse && ( -
+ {/* 하단 버튼 영역 */} +
+ {/* 초기화 드롭다운 */} + + + + + + + + 필터만 초기화 + {filteredFieldCount > 0 && ( + + ({filteredFieldCount}개) + + )} + + + + 필드 배치 초기화 + + + + + 전체 초기화 + + + + + {/* 접기 버튼 */} + {onToggleCollapse && ( -
- )} + )} +
{/* 드래그 오버레이 */} -- 2.43.0 From 62a82b3bcfb2020db793fd08b40e47fdd49cef3a Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 16:40:37 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EB=B0=91=EA=BA=BD=EC=87=A0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=96=88=EC=9D=8C!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridConfigPanel.tsx | 26 ++++++- .../pivot-grid/components/FieldPanel.tsx | 71 +++++++++++++++++++ .../registry/components/pivot-grid/types.ts | 1 + .../pivot-grid/utils/pivotEngine.ts | 4 +- 4 files changed, 100 insertions(+), 2 deletions(-) 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" && ( + + )} + )} + + {/* 상태 유지 체크박스 */} +
+ 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, }; } -- 2.43.0 From 2327d6e97c0479a656ec30710f69c90743e8a845 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 17:14:07 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EC=83=81=ED=83=9C=EC=9C=A0=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 8db463c4..13cb1a68 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -447,8 +447,8 @@ export const PivotGridComponent: React.FC = ({ useEffect(() => { if (!isStateRestored) return; // 복원 완료 전에는 무시 - // persistState가 켜져있고 저장된 상태가 있으면 initialFields로 덮어쓰지 않음 - if (persistState && typeof window !== "undefined") { + // 저장된 상태가 있으면 initialFields로 덮어쓰지 않음 + if (typeof window !== "undefined") { const savedState = localStorage.getItem(stateStorageKey); if (savedState) return; // 이미 저장된 상태가 있으면 무시 } @@ -456,13 +456,32 @@ export const PivotGridComponent: React.FC = ({ if (initialFields.length > 0) { setFields(initialFields); } - }, [initialFields, isStateRestored, persistState, stateStorageKey]); + // persistState는 의존성에서 제외 - 체크박스 변경 시 현재 상태 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialFields, isStateRestored, stateStorageKey]); - // 상태 유지 설정 저장 + // 상태 유지 설정 저장 + 켜질 때 현재 상태 즉시 저장 useEffect(() => { if (typeof window === "undefined") return; localStorage.setItem(persistSettingKey, String(persistState)); - }, [persistState, persistSettingKey]); + + // 상태 유지를 켜면 현재 상태를 즉시 저장 + if (persistState && isStateRestored && fields.length > 0) { + const stateToSave = { + version: PIVOT_STATE_VERSION, + fields, + pivotState, + sortConfig, + columnWidths, + }; + localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); + } + + // 상태 유지를 끄면 저장된 상태 삭제 + if (!persistState) { + localStorage.removeItem(stateStorageKey); + } + }, [persistState, persistSettingKey, isStateRestored, fields, pivotState, sortConfig, columnWidths, stateStorageKey]); // 상태 저장 (localStorage) const saveStateToStorage = useCallback(() => { -- 2.43.0
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} + > + 총계 +