lhj #376

Merged
hjlee merged 5 commits from lhj into main 2026-01-21 17:15:48 +09:00
3 changed files with 413 additions and 149 deletions
Showing only changes of commit 63a4753701 - Show all commits

View File

@ -7,6 +7,8 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { import {
PivotGridProps, PivotGridProps,
PivotResult, PivotResult,
@ -50,6 +52,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
// ==================== 상수 ====================
const PIVOT_STATE_VERSION = "1.0"; // 상태 저장 버전 (호환성 체크용)
// ==================== 유틸리티 함수 ==================== // ==================== 유틸리티 함수 ====================
// 셀 병합 정보 계산 // 셀 병합 정보 계산
@ -128,7 +134,10 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{row.hasChildren && ( {row.hasChildren && (
<button <button
onClick={() => onToggleExpand(row.path)} onClick={(e) => {
e.stopPropagation();
onToggleExpand(row.path);
}}
className="p-0.5 hover:bg-accent rounded" className="p-0.5 hover:bg-accent rounded"
> >
{row.isExpanded ? ( {row.isExpanded ? (
@ -346,41 +355,44 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const [resizeStartX, setResizeStartX] = useState<number>(0); const [resizeStartX, setResizeStartX] = useState<number>(0);
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0); const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
// 외부 fields 변경 시 동기화
useEffect(() => {
if (initialFields.length > 0) {
setFields(initialFields);
}
}, [initialFields]);
// 상태 저장 키 // 상태 저장 키
const stateStorageKey = `pivot-state-${title || "default"}`; const stateStorageKey = `pivot-state-${title || "default"}`;
const persistSettingKey = `pivot-persist-${title || "default"}`;
// 상태 저장 (localStorage) // 상태 유지 설정 (체크박스용)
const saveStateToStorage = useCallback(() => { const [persistState, setPersistState] = useState<boolean>(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return true;
const stateToSave = { const saved = localStorage.getItem(persistSettingKey);
fields, return saved !== null ? saved === "true" : true; // 기본값 true
pivotState, });
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 복원 (localStorage) - 프로덕션 안전성 강화 // 복원 완료 여부 (initialFields 덮어쓰기 방지)
const [isStateRestored, setIsStateRestored] = useState(false);
// 상태 복원 (localStorage) - 마운트 시 한 번만 실행
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// 상태 유지가 꺼져 있으면 복원하지 않음
if (!persistState) {
localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return;
}
try { try {
const savedState = localStorage.getItem(stateStorageKey); const savedState = localStorage.getItem(stateStorageKey);
if (!savedState) return; if (!savedState) {
setIsStateRestored(true);
return;
}
const parsed = JSON.parse(savedState); const parsed = JSON.parse(savedState);
// 버전 체크 - 버전이 다르면 이전 상태 무시 // 버전 체크 - 버전이 다르면 이전 상태 무시
if (parsed.version !== PIVOT_STATE_VERSION) { if (parsed.version !== PIVOT_STATE_VERSION) {
localStorage.removeItem(stateStorageKey); localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return; return;
} }
@ -426,7 +438,53 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 손상된 상태는 제거 // 손상된 상태는 제거
localStorage.removeItem(stateStorageKey); localStorage.removeItem(stateStorageKey);
} }
}, [stateStorageKey]);
setIsStateRestored(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 마운트 시 한 번만 실행
// 외부 fields 변경 시 동기화 (복원이 완료된 후에만, 저장된 상태가 없을 때만)
useEffect(() => {
if (!isStateRestored) return; // 복원 완료 전에는 무시
// persistState가 켜져있고 저장된 상태가 있으면 initialFields로 덮어쓰지 않음
if (persistState && typeof window !== "undefined") {
const savedState = localStorage.getItem(stateStorageKey);
if (savedState) return; // 이미 저장된 상태가 있으면 무시
}
if (initialFields.length > 0) {
setFields(initialFields);
}
}, [initialFields, isStateRestored, persistState, stateStorageKey]);
// 상태 유지 설정 저장
useEffect(() => {
if (typeof window === "undefined") return;
localStorage.setItem(persistSettingKey, String(persistState));
}, [persistState, persistSettingKey]);
// 상태 저장 (localStorage)
const saveStateToStorage = useCallback(() => {
if (typeof window === "undefined" || !persistState) return;
const stateToSave = {
version: PIVOT_STATE_VERSION,
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey, persistState]);
// 상태 변경 시 자동 저장 (복원 완료 후에만)
useEffect(() => {
if (!persistState || !isStateRestored) return;
// 초기 로드 후에만 저장 (빈 필드일 때는 저장 안 함)
if (fields.length > 0) {
saveStateToStorage();
}
}, [fields, pivotState, sortConfig, columnWidths, persistState, isStateRestored, saveStateToStorage]);
// 데이터 // 데이터
const data = externalData || []; const data = externalData || [];
@ -1173,7 +1231,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
); );
} }
const { flatColumns, dataMatrix, grandTotals } = pivotResult; const { flatColumns, dataMatrix, grandTotals, columnHeaderLevels } = pivotResult;
// ==================== 키보드 네비게이션 ==================== // ==================== 키보드 네비게이션 ====================
@ -1470,6 +1528,22 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</Button> </Button>
</> </>
)} )}
{/* 상태 유지 체크박스 */}
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l">
<Checkbox
id="persist-state"
checked={persistState}
onCheckedChange={(checked) => setPersistState(checked === true)}
className="h-3.5 w-3.5"
/>
<Label
htmlFor="persist-state"
className="text-xs text-muted-foreground cursor-pointer whitespace-nowrap"
>
</Label>
</div>
{/* 차트 토글 */} {/* 차트 토글 */}
{chartConfig && ( {chartConfig && (
@ -1689,137 +1763,224 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
> >
<table ref={tableRef} className="w-full border-collapse"> <table ref={tableRef} className="w-full border-collapse">
<thead> <thead>
{/* 열 헤더 */} {/* 다중 행 열 헤더 */}
<tr className="bg-background"> {columnHeaderLevels.length > 0 ? (
{/* 좌상단 코너 (행 필드 라벨 + 필터) */} // 열 필드가 있는 경우: 각 레벨별로 행 생성
<th columnHeaderLevels.map((levelCells, levelIdx) => (
className={cn( <tr key={`col-level-${levelIdx}`} className="bg-background">
"border-r border-b border-border", {/* 좌상단 코너 (첫 번째 레벨에만 표시) */}
"px-2 py-1 text-left text-xs font-medium", {levelIdx === 0 && (
"bg-background sticky left-0 top-0 z-20" <th
)} className={cn(
rowSpan={columnFields.length > 0 ? 2 : 1} "border-r border-b border-border",
> "px-2 py-1 text-left text-xs font-medium",
<div className="flex items-center gap-1 flex-wrap"> "bg-background sticky left-0 top-0 z-20"
{rowFields.map((f, idx) => ( )}
<div key={f.field} className="flex items-center gap-0.5 group"> rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
<span>{f.caption}</span> >
<FilterPopup <div className="flex items-center gap-1 flex-wrap">
field={f} {rowFields.map((f, idx) => (
data={data} <div key={f.field} className="flex items-center gap-0.5 group">
onFilterChange={(field, values, type) => { <span>{f.caption}</span>
const newFields = fields.map((fld) => <FilterPopup
fld.field === field.field && fld.area === "row" field={f}
? { ...fld, filterValues: values, filterType: type } data={data}
: fld onFilterChange={(field, values, type) => {
); const newFields = fields.map((fld) =>
handleFieldsChange(newFields); fld.field === field.field && fld.area === "row"
}} ? { ...fld, filterValues: values, filterType: type }
trigger={ : fld
<button );
className={cn( handleFieldsChange(newFields);
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity", }}
"hover:bg-accent", trigger={
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary" <button
)} className={cn(
> "p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
<Filter className="h-3 w-3" /> "hover:bg-accent",
</button> f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
} )}
/> >
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>} <Filter className="h-3 w-3" />
</div> </button>
}
/>
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
</div>
))}
{rowFields.length === 0 && <span></span>}
</div>
</th>
)}
{/* 열 헤더 셀 - 해당 레벨 */}
{levelCells.map((cell, cellIdx) => (
<th
key={`${levelIdx}-${cellIdx}`}
className={cn(
"border-r border-b border-border relative",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={cell.colSpan * (dataFields.length || 1)}
>
<div className="flex items-center justify-center gap-1">
<span>{cell.caption || "(전체)"}</span>
{levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && (
<SortIcon field={dataFields[0].field} />
)}
</div>
</th>
))} ))}
{rowFields.length === 0 && <span></span>}
</div>
</th>
{/* 열 헤더 셀 */} {/* 행 총계 헤더 (첫 번째 레벨에만 표시) */}
{flatColumns.map((col, idx) => ( {levelIdx === 0 && totals?.showRowGrandTotals && (
<th <th
key={idx} className={cn(
className={cn( "border-b border-border",
"border-r border-b border-border relative group", "px-2 py-1 text-center text-xs font-medium",
"px-2 py-1 text-center text-xs font-medium", "bg-background sticky top-0 z-10"
"bg-background sticky top-0 z-10", )}
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50" colSpan={dataFields.length || 1}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
</th>
)} )}
colSpan={dataFields.length || 1}
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }} {/* 열 필드 필터 (첫 번째 레벨에만 표시) */}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} {levelIdx === 0 && columnFields.length > 0 && (
> <th
<div className="flex items-center justify-center gap-1"> className={cn(
<span>{col.caption || "(전체)"}</span> "border-b border-border",
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />} "px-1 py-1 text-center text-xs",
</div> "bg-background sticky top-0 z-10"
{/* 열 리사이즈 핸들 */} )}
<div rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
className={cn( >
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize", <div className="flex flex-col gap-0.5">
"hover:bg-primary/50 transition-colors", {columnFields.map((f) => (
resizingColumn === idx && "bg-primary" <FilterPopup
)} key={f.field}
onMouseDown={(e) => handleResizeStart(idx, e)} field={f}
/> data={data}
</th> onFilterChange={(field, values, type) => {
))} const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "column"
{/* 행 총계 헤더 */} ? { ...fld, filterValues: values, filterType: type }
{totals?.showRowGrandTotals && ( : fld
<th );
className={cn( handleFieldsChange(newFields);
"border-b border-border", }}
"px-2 py-1 text-center text-xs font-medium", trigger={
"bg-background sticky top-0 z-10" <button
className={cn(
"p-0.5 rounded hover:bg-accent",
f.filterValues && f.filterValues.length > 0 && "text-primary"
)}
title={`${f.caption} 필터`}
>
<Filter className="h-3 w-3" />
</button>
}
/>
))}
</div>
</th>
)} )}
colSpan={dataFields.length || 1} </tr>
rowSpan={dataFields.length > 1 ? 2 : 1} ))
> ) : (
// 열 필드가 없는 경우: 단일 행
</th> <tr className="bg-background">
)}
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
{columnFields.length > 0 && (
<th <th
className={cn( className={cn(
"border-b border-border", "border-r border-b border-border",
"px-1 py-1 text-center text-xs", "px-2 py-1 text-left text-xs font-medium",
"bg-background sticky top-0 z-10" "bg-background sticky left-0 top-0 z-20"
)} )}
rowSpan={dataFields.length > 1 ? 2 : 1} rowSpan={dataFields.length > 1 ? 2 : 1}
> >
<div className="flex flex-col gap-0.5"> <div className="flex items-center gap-1 flex-wrap">
{columnFields.map((f) => ( {rowFields.map((f, idx) => (
<FilterPopup <div key={f.field} className="flex items-center gap-0.5 group">
key={f.field} <span>{f.caption}</span>
field={f} <FilterPopup
data={data} field={f}
onFilterChange={(field, values, type) => { data={data}
const newFields = fields.map((fld) => onFilterChange={(field, values, type) => {
fld.field === field.field && fld.area === "column" const newFields = fields.map((fld) =>
? { ...fld, filterValues: values, filterType: type } fld.field === field.field && fld.area === "row"
: fld ? { ...fld, filterValues: values, filterType: type }
); : fld
handleFieldsChange(newFields); );
}} handleFieldsChange(newFields);
trigger={ }}
<button trigger={
className={cn( <button
"p-0.5 rounded hover:bg-accent", className={cn(
f.filterValues && f.filterValues.length > 0 && "text-primary" "p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
)} "hover:bg-accent",
title={`${f.caption} 필터`} f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
> )}
<Filter className="h-3 w-3" /> >
</button> <Filter className="h-3 w-3" />
} </button>
/> }
/>
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
</div>
))} ))}
{rowFields.length === 0 && <span></span>}
</div> </div>
</th> </th>
)}
</tr> {/* 열 헤더 셀 (열 필드 없을 때) */}
{flatColumns.map((col, idx) => (
<th
key={idx}
className={cn(
"border-r border-b border-border relative group",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={dataFields.length || 1}
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
>
<div className="flex items-center justify-center gap-1">
<span>{col.caption || "(전체)"}</span>
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
</div>
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
"hover:bg-primary/50 transition-colors",
resizingColumn === idx && "bg-primary"
)}
onMouseDown={(e) => handleResizeStart(idx, e)}
/>
</th>
))}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
rowSpan={dataFields.length > 1 ? 2 : 1}
>
</th>
)}
</tr>
)}
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && ( {dataFields.length > 1 && (

View File

@ -331,8 +331,11 @@ export interface PivotResult {
// 플랫 행 목록 (렌더링용) // 플랫 행 목록 (렌더링용)
flatRows: PivotFlatRow[]; flatRows: PivotFlatRow[];
// 플랫 열 목록 (렌더링용) // 플랫 열 목록 (렌더링용) - 리프 노드만
flatColumns: PivotFlatColumn[]; flatColumns: PivotFlatColumn[];
// 열 헤더 레벨별 (다중 행 헤더용)
columnHeaderLevels: PivotColumnHeaderCell[][];
// 총합계 // 총합계
grandTotals: { grandTotals: {
@ -361,6 +364,14 @@ export interface PivotFlatColumn {
isTotal?: boolean; isTotal?: boolean;
} }
// 열 헤더 셀 (다중 행 헤더용)
export interface PivotColumnHeaderCell {
caption: string; // 표시 텍스트
colSpan: number; // 병합할 열 수
path: string[]; // 전체 경로
level: number; // 레벨 (0부터 시작)
}
// ==================== 상태 관리 ==================== // ==================== 상태 관리 ====================
export interface PivotGridState { export interface PivotGridState {

View File

@ -10,6 +10,7 @@ import {
PivotFlatRow, PivotFlatRow,
PivotFlatColumn, PivotFlatColumn,
PivotCellValue, PivotCellValue,
PivotColumnHeaderCell,
DateGroupInterval, DateGroupInterval,
AggregationType, AggregationType,
SummaryDisplayMode, SummaryDisplayMode,
@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string {
return path.join("||"); return path.join("||");
} }
/**
* ( )
*/
function generateAllPaths(
data: Record<string, any>[],
fields: PivotFieldConfig[]
): string[] {
const allPaths: string[] = [];
// 각 레벨까지의 고유 경로 수집
for (let depth = 1; depth <= fields.length; depth++) {
const fieldsAtDepth = fields.slice(0, depth);
const pathSet = new Set<string>();
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); 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)); uniqueValues.forEach((val) => expandedRowSet.add(val));
} }
if (expandedColumnPaths.length === 0 && columnFields.length > 0) { // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음)
const firstField = columnFields[0]; // 모든 가능한 열 경로를 확장 상태로 설정
const uniqueValues = new Set( if (columnFields.length > 0) {
filteredData.map((row) => getFieldValue(row, firstField)) const allColumnPaths = generateAllPaths(filteredData, columnFields);
); allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey));
uniqueValues.forEach((val) => expandedColSet.add(val));
} }
// 헤더 트리 생성 // 헤더 트리 생성
@ -788,6 +873,12 @@ export function processPivotData(
grandTotals.grand grandTotals.grand
); );
// 다중 행 열 헤더 생성
const columnHeaderLevels = buildColumnHeaderLevels(
columnHeaders,
columnFields.length
);
return { return {
rowHeaders, rowHeaders,
columnHeaders, columnHeaders,
@ -799,6 +890,7 @@ export function processPivotData(
caption: path[path.length - 1] || "", caption: path[path.length - 1] || "",
span: 1, span: 1,
})), })),
columnHeaderLevels,
grandTotals, grandTotals,
}; };
} }