피벗 추가

This commit is contained in:
leeheejin 2026-01-09 14:41:27 +09:00
parent 819a281df4
commit d49883d25f
5 changed files with 1122 additions and 913 deletions

View File

@ -5,7 +5,7 @@
*
*/
import React, { useState, useMemo, useCallback, useEffect } from "react";
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import {
PivotGridProps,
@ -15,7 +15,6 @@ import {
PivotFlatRow,
PivotCellValue,
PivotGridState,
PivotAreaType,
} from "./types";
import { processPivotData, pathToKey } from "./utils/pivotEngine";
import { exportPivotToExcel } from "./utils/exportExcel";
@ -24,6 +23,8 @@ import { FieldPanel } from "./components/FieldPanel";
import { FieldChooser } from "./components/FieldChooser";
import { DrillDownModal } from "./components/DrillDownModal";
import { PivotChart } from "./components/PivotChart";
import { FilterPopup } from "./components/FilterPopup";
import { useVirtualScroll } from "./hooks/useVirtualScroll";
import {
ChevronRight,
ChevronDown,
@ -35,6 +36,10 @@ import {
LayoutGrid,
FileSpreadsheet,
BarChart3,
Filter,
ArrowUp,
ArrowDown,
ArrowUpDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -88,6 +93,7 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
interface DataCellProps {
values: PivotCellValue[];
isTotal?: boolean;
isSelected?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
conditionalStyle?: CellFormatStyle;
@ -96,6 +102,7 @@ interface DataCellProps {
const DataCell: React.FC<DataCellProps> = ({
values,
isTotal = false,
isSelected = false,
onClick,
onDoubleClick,
conditionalStyle,
@ -104,6 +111,9 @@ const DataCell: React.FC<DataCellProps> = ({
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
const icon = conditionalStyle?.icon;
// 선택 상태 스타일
const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10";
if (!values || values.length === 0) {
return (
@ -111,7 +121,8 @@ const DataCell: React.FC<DataCellProps> = ({
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-right text-sm",
isTotal && "bg-primary/5 font-medium"
isTotal && "bg-primary/5 font-medium",
selectedClass
)}
style={cellStyle}
onClick={onClick}
@ -130,7 +141,8 @@ const DataCell: React.FC<DataCellProps> = ({
"border-r border-b border-border relative",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50",
selectedClass
)}
style={cellStyle}
onClick={onClick}
@ -164,7 +176,8 @@ const DataCell: React.FC<DataCellProps> = ({
"border-r border-b border-border relative",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50",
selectedClass
)}
style={cellStyle}
onClick={onClick}
@ -237,13 +250,28 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
filterConfig: {},
});
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFieldPanel, setShowFieldPanel] = useState(true);
const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태
const [showFieldChooser, setShowFieldChooser] = useState(false);
const [drillDownData, setDrillDownData] = useState<{
open: boolean;
cellData: PivotCellData | null;
}>({ open: false, cellData: null });
const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
const [containerHeight, setContainerHeight] = useState(400);
const tableContainerRef = useRef<HTMLDivElement>(null);
// 셀 선택 상태
const [selectedCell, setSelectedCell] = useState<{
rowIndex: number;
colIndex: number;
} | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
// 정렬 상태
const [sortConfig, setSortConfig] = useState<{
field: string;
direction: "asc" | "desc";
} | null>(null);
// 외부 fields 변경 시 동기화
useEffect(() => {
@ -281,6 +309,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
[fields]
);
// 필터 영역 필드
const filterFields = useMemo(
() =>
fields
@ -318,25 +347,53 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
});
}, [data, fields]);
// ==================== 필터 적용 ====================
const filteredData = useMemo(() => {
if (!data || data.length === 0) return data;
// 필터 영역의 필드들로 데이터 필터링
const activeFilters = fields.filter(
(f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0
);
if (activeFilters.length === 0) return data;
return data.filter((row) => {
return activeFilters.every((filter) => {
const value = row[filter.field];
const filterValues = filter.filterValues || [];
const filterType = filter.filterType || "include";
if (filterType === "include") {
return filterValues.includes(value);
} else {
return !filterValues.includes(value);
}
});
});
}, [data, fields]);
// ==================== 피벗 처리 ====================
const pivotResult = useMemo<PivotResult | null>(() => {
if (!data || data.length === 0 || fields.length === 0) {
if (!filteredData || filteredData.length === 0 || fields.length === 0) {
return null;
}
const visibleFields = fields.filter((f) => f.visible !== false);
if (visibleFields.filter((f) => f.area !== "filter").length === 0) {
// 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외)
if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) {
return null;
}
return processPivotData(
data,
filteredData,
visibleFields,
pivotState.expandedRowPaths,
pivotState.expandedColumnPaths
);
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
}, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
// 조건부 서식용 전체 값 수집
const allCellValues = useMemo(() => {
@ -380,6 +437,42 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
return valuesByField;
}, [pivotResult]);
// ==================== 가상 스크롤 ====================
const ROW_HEIGHT = 32; // 행 높이 (px)
const VIRTUAL_SCROLL_THRESHOLD = 50; // 이 행 수 이상이면 가상 스크롤 활성화
// 컨테이너 높이 측정
useEffect(() => {
if (!tableContainerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerHeight(entry.contentRect.height);
}
});
observer.observe(tableContainerRef.current);
return () => observer.disconnect();
}, []);
// 가상 스크롤 훅 사용
const flatRows = pivotResult?.flatRows || [];
const enableVirtualScroll = flatRows.length > VIRTUAL_SCROLL_THRESHOLD;
const virtualScroll = useVirtualScroll({
itemCount: flatRows.length,
itemHeight: ROW_HEIGHT,
containerHeight: containerHeight,
overscan: 10,
});
// 가상 스크롤 적용된 행 데이터
const visibleFlatRows = useMemo(() => {
if (!enableVirtualScroll) return flatRows;
return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1);
}, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]);
// 조건부 서식 스타일 계산 헬퍼
const getCellConditionalStyle = useCallback(
(value: number | undefined, field: string): CellFormatStyle => {
@ -587,9 +680,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
);
}
// 필드 미설정
// 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인)
const hasActiveFields = fields.some(
(f) => f.visible !== false && f.area !== "filter"
(f) => f.visible !== false && ["row", "column", "data"].includes(f.area)
);
if (!hasActiveFields) {
return (
@ -646,7 +739,125 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
);
}
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
const { flatColumns, dataMatrix, grandTotals } = pivotResult;
// ==================== 키보드 네비게이션 ====================
// 키보드 핸들러
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!selectedCell) return;
const { rowIndex, colIndex } = selectedCell;
const maxRowIndex = visibleFlatRows.length - 1;
const maxColIndex = flatColumns.length - 1;
let newRowIndex = rowIndex;
let newColIndex = colIndex;
switch (e.key) {
case "ArrowUp":
e.preventDefault();
newRowIndex = Math.max(0, rowIndex - 1);
break;
case "ArrowDown":
e.preventDefault();
newRowIndex = Math.min(maxRowIndex, rowIndex + 1);
break;
case "ArrowLeft":
e.preventDefault();
newColIndex = Math.max(0, colIndex - 1);
break;
case "ArrowRight":
e.preventDefault();
newColIndex = Math.min(maxColIndex, colIndex + 1);
break;
case "Home":
e.preventDefault();
if (e.ctrlKey) {
newRowIndex = 0;
newColIndex = 0;
} else {
newColIndex = 0;
}
break;
case "End":
e.preventDefault();
if (e.ctrlKey) {
newRowIndex = maxRowIndex;
newColIndex = maxColIndex;
} else {
newColIndex = maxColIndex;
}
break;
case "PageUp":
e.preventDefault();
newRowIndex = Math.max(0, rowIndex - 10);
break;
case "PageDown":
e.preventDefault();
newRowIndex = Math.min(maxRowIndex, rowIndex + 10);
break;
case "Enter":
e.preventDefault();
// 셀 더블클릭과 동일한 동작 (드릴다운)
if (visibleFlatRows[rowIndex] && flatColumns[colIndex]) {
const row = visibleFlatRows[rowIndex];
const col = flatColumns[colIndex];
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
// 드릴다운 모달 열기
const cellData: PivotCellData = {
value: values[0]?.value,
rowPath: row.path,
columnPath: col.path,
field: values[0]?.field,
};
setDrillDownData({ open: true, cellData });
}
break;
case "Escape":
e.preventDefault();
setSelectedCell(null);
break;
default:
return;
}
if (newRowIndex !== rowIndex || newColIndex !== colIndex) {
setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex });
}
};
// 셀 클릭으로 선택
const handleCellSelect = (rowIndex: number, colIndex: number) => {
setSelectedCell({ rowIndex, colIndex });
};
// 정렬 토글
const handleSort = (field: string) => {
setSortConfig((prev) => {
if (prev?.field === field) {
// 같은 필드 클릭: asc -> desc -> null 순환
if (prev.direction === "asc") {
return { field, direction: "desc" };
}
return null; // 정렬 해제
}
// 새로운 필드: asc로 시작
return { field, direction: "asc" };
});
};
// 정렬 아이콘 렌더링
const SortIcon = ({ field }: { field: string }) => {
if (sortConfig?.field !== field) {
return <ArrowUpDown className="h-3 w-3 opacity-30" />;
}
if (sortConfig.direction === "asc") {
return <ArrowUp className="h-3 w-3 text-primary" />;
}
return <ArrowDown className="h-3 w-3 text-primary" />;
};
return (
<div
@ -674,7 +885,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
<div className="flex items-center gap-2">
{title && <h3 className="text-sm font-medium">{title}</h3>}
<span className="text-xs text-muted-foreground">
({data.length})
({filteredData.length !== data.length
? `${filteredData.length} / ${data.length}`
: `${data.length}`})
</span>
</div>
@ -780,13 +993,68 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</div>
</div>
{/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */}
{filterFields.length > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-orange-50/50 dark:bg-orange-950/10">
<Filter className="h-3.5 w-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">:</span>
<div className="flex items-center gap-2 flex-wrap">
{filterFields.map((filterField) => {
const selectedValues = filterField.filterValues || [];
const isFiltered = selectedValues.length > 0;
return (
<FilterPopup
key={filterField.field}
field={filterField}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((f) =>
f.field === field.field && f.area === field.area
? { ...f, filterValues: values, filterType: type }
: f
);
handleFieldsChange(newFields);
}}
trigger={
<button
className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded text-xs",
"border transition-colors",
isFiltered
? "bg-orange-100 border-orange-300 text-orange-800 dark:bg-orange-900/30 dark:border-orange-700 dark:text-orange-200"
: "bg-background border-border hover:bg-accent"
)}
>
<span>{filterField.caption}</span>
{isFiltered && (
<span className="bg-orange-500 text-white px-1 rounded text-[10px]">
{selectedValues.length}
</span>
)}
<ChevronDown className="h-3 w-3" />
</button>
}
/>
);
})}
</div>
</div>
)}
{/* 피벗 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<div
ref={tableContainerRef}
className="flex-1 overflow-auto focus:outline-none"
style={{ maxHeight: enableVirtualScroll ? containerHeight : undefined }}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<table ref={tableRef} className="w-full border-collapse">
<thead>
{/* 열 헤더 */}
<tr className="bg-muted/50">
{/* 좌상단 코너 (행 필드 라벨) */}
{/* 좌상단 코너 (행 필드 라벨 + 필터) */}
<th
className={cn(
"border-r border-b border-border",
@ -795,7 +1063,38 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
)}
rowSpan={columnFields.length > 0 ? 2 : 1}
>
{rowFields.map((f) => f.caption).join(" / ") || "항목"}
<div className="flex items-center gap-1 flex-wrap">
{rowFields.map((f, idx) => (
<div key={f.field} className="flex items-center gap-0.5 group">
<span>{f.caption}</span>
<FilterPopup
field={f}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "row"
? { ...fld, filterValues: values, filterType: type }
: fld
);
handleFieldsChange(newFields);
}}
trigger={
<button
className={cn(
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
"hover:bg-accent",
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
)}
>
<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>
</th>
{/* 열 헤더 셀 */}
@ -805,13 +1104,59 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-muted/70 sticky top-0 z-10"
"bg-muted/70 sticky top-0 z-10",
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={dataFields.length || 1}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
>
{col.caption || "(전체)"}
<div className="flex items-center justify-center gap-1">
<span>{col.caption || "(전체)"}</span>
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
</div>
</th>
))}
{/* 열 필드 필터 (헤더 왼쪽에 표시) */}
{columnFields.length > 0 && (
<th
className={cn(
"border-b border-border",
"px-1 py-1.5 text-center text-xs",
"bg-muted/50 sticky top-0 z-10"
)}
rowSpan={columnFields.length > 0 ? 2 : 1}
>
<div className="flex flex-col gap-0.5">
{columnFields.map((f) => (
<FilterPopup
key={f.field}
field={f}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "column"
? { ...fld, filterValues: values, filterType: type }
: fld
);
handleFieldsChange(newFields);
}}
trigger={
<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>
)}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
@ -839,10 +1184,14 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-center text-xs font-normal",
"text-muted-foreground"
"text-muted-foreground cursor-pointer hover:bg-accent/50"
)}
onClick={() => handleSort(df.field)}
>
{df.caption}
<div className="flex items-center justify-center gap-1">
<span>{df.caption}</span>
<SortIcon field={df.field} />
</div>
</th>
))}
</React.Fragment>
@ -865,59 +1214,84 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</thead>
<tbody>
{flatRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className={cn(
style?.alternateRowColors &&
rowIdx % 2 === 1 &&
"bg-muted/20"
)}
>
{/* 행 헤더 */}
<RowHeaderCell
row={row}
rowFields={rowFields}
onToggleExpand={handleToggleRowExpand}
/>
{/* 데이터 셀 */}
{flatColumns.map((col, colIdx) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
// 조건부 서식 (첫 번째 값 기준)
const conditionalStyle =
values.length > 0 && values[0].field
? getCellConditionalStyle(values[0].value, values[0].field)
: undefined;
return (
<DataCell
key={colIdx}
values={values}
conditionalStyle={conditionalStyle}
onClick={
onCellClick
? () => handleCellClick(row.path, col.path, values)
: undefined
}
onDoubleClick={() =>
handleCellDoubleClick(row.path, col.path, values)
}
/>
);
})}
{/* 행 총계 */}
{totals?.showRowGrandTotals && (
<DataCell
values={grandTotals.row.get(pathToKey(row.path)) || []}
isTotal
/>
)}
{/* 가상 스크롤 상단 여백 */}
{enableVirtualScroll && virtualScroll.offsetTop > 0 && (
<tr style={{ height: virtualScroll.offsetTop }}>
<td colSpan={rowFields.length + flatColumns.length + (totals?.showRowGrandTotals ? dataFields.length : 0)} />
</tr>
))}
)}
{visibleFlatRows.map((row, idx) => {
// 실제 행 인덱스 계산
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
return (
<tr
key={rowIdx}
className={cn(
style?.alternateRowColors &&
rowIdx % 2 === 1 &&
"bg-muted/20"
)}
style={{ height: ROW_HEIGHT }}
>
{/* 행 헤더 */}
<RowHeaderCell
row={row}
rowFields={rowFields}
onToggleExpand={handleToggleRowExpand}
/>
{/* 데이터 셀 */}
{flatColumns.map((col, colIdx) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
// 조건부 서식 (첫 번째 값 기준)
const conditionalStyle =
values.length > 0 && values[0].field
? getCellConditionalStyle(values[0].value ?? undefined, values[0].field)
: undefined;
// 선택 상태 확인
const isCellSelected = selectedCell?.rowIndex === rowIdx && selectedCell?.colIndex === colIdx;
return (
<DataCell
key={colIdx}
values={values}
conditionalStyle={conditionalStyle}
isSelected={isCellSelected}
onClick={() => {
handleCellSelect(rowIdx, colIdx);
if (onCellClick) {
handleCellClick(row.path, col.path, values);
}
}}
onDoubleClick={() =>
handleCellDoubleClick(row.path, col.path, values)
}
/>
);
})}
{/* 행 총계 */}
{totals?.showRowGrandTotals && (
<DataCell
values={grandTotals.row.get(pathToKey(row.path)) || []}
isTotal
/>
)}
</tr>
);
})}
{/* 가상 스크롤 하단 여백 */}
{enableVirtualScroll && (
<tr style={{ height: virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT) }}>
<td colSpan={rowFields.length + flatColumns.length + (totals?.showRowGrandTotals ? dataFields.length : 0)} />
</tr>
)}
{/* 열 총계 행 */}
{totals?.showColumnGrandTotals && (

View File

@ -0,0 +1,213 @@
"use client";
/**
* PivotGrid
* , , /
*/
import React from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
ArrowUpAZ,
ArrowDownAZ,
Filter,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
BarChart3,
} from "lucide-react";
import { PivotFieldConfig, AggregationType } from "../types";
interface PivotContextMenuProps {
children: React.ReactNode;
// 현재 컨텍스트 정보
cellType: "header" | "data" | "rowHeader" | "columnHeader";
field?: PivotFieldConfig;
rowPath?: string[];
columnPath?: string[];
value?: any;
// 콜백
onSort?: (field: string, direction: "asc" | "desc") => void;
onFilter?: (field: string) => void;
onExpand?: (path: string[]) => void;
onCollapse?: (path: string[]) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
onCopy?: (value: any) => void;
onHideField?: (field: string) => void;
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
}
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
children,
cellType,
field,
rowPath,
columnPath,
value,
onSort,
onFilter,
onExpand,
onCollapse,
onExpandAll,
onCollapseAll,
onCopy,
onHideField,
onChangeSummary,
onDrillDown,
}) => {
const handleCopy = () => {
if (value !== undefined && value !== null) {
navigator.clipboard.writeText(String(value));
onCopy?.(value);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 정렬 옵션 (헤더에서만) */}
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
<ArrowDownAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 확장/축소 옵션 */}
{(cellType === "rowHeader" || cellType === "columnHeader") && (
<>
{rowPath && rowPath.length > 0 && (
<>
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpandAll}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={onCollapseAll}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필터 옵션 */}
{field && onFilter && (
<>
<ContextMenuItem onClick={() => onFilter(field.field)}>
<Filter className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 집계 함수 변경 (데이터 필드에서만) */}
{cellType === "data" && field && onChangeSummary && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<BarChart3 className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "sum")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "count")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "avg")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "min")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "max")}
>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 드릴다운 (데이터 셀에서만) */}
{cellType === "data" && rowPath && columnPath && onDrillDown && (
<>
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
<Eye className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필드 숨기기 */}
{field && onHideField && (
<ContextMenuItem onClick={() => onHideField(field.field)}>
<EyeOff className="mr-2 h-4 w-4" />
</ContextMenuItem>
)}
{/* 복사 */}
<ContextMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export default PivotContextMenu;

View File

@ -2,7 +2,7 @@
/**
* FieldPanel
* (, , , )
* (, , )
*
*/
@ -247,7 +247,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
return (
<div
className={cn(
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
"transition-colors duration-200",
config.color,
isOver && "border-primary bg-primary/5"
@ -255,7 +255,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
data-area={area}
>
{/* 영역 헤더 */}
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
{icon}
<span>{title}</span>
{areaFields.length > 0 && (
@ -267,9 +267,9 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
{/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
<div className="flex flex-wrap gap-1 min-h-[22px]">
{areaFields.length === 0 ? (
<span className="text-xs text-muted-foreground/50 italic">
<span className="text-[10px] text-muted-foreground/50 italic">
</span>
) : (
@ -443,16 +443,42 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
? fields.find((f) => `${f.area}-${f.field}` === activeId)
: null;
// 각 영역의 필드 수 계산
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
if (collapsed) {
return (
<div className="border-b border-border px-3 py-2">
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{filterCount > 0 && (
<span className="flex items-center gap-1">
<Filter className="h-3 w-3" />
{filterCount}
</span>
)}
<span className="flex items-center gap-1">
<Columns className="h-3 w-3" />
{columnCount}
</span>
<span className="flex items-center gap-1">
<Rows className="h-3 w-3" />
{rowCount}
</span>
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{dataCount}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs"
className="text-xs h-6 px-2"
>
</Button>
</div>
);
@ -466,9 +492,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="border-b border-border bg-muted/20 p-3">
{/* 2x2 그리드로 영역 배치 */}
<div className="grid grid-cols-2 gap-2">
<div className="border-b border-border bg-muted/20 p-2">
{/* 4개 영역 배치: 2x2 그리드 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 필터 영역 */}
<DroppableArea
area="filter"
@ -516,12 +542,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
{/* 접기 버튼 */}
{onToggleCollapse && (
<div className="flex justify-center mt-2">
<div className="flex justify-center mt-1.5">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-6"
className="text-xs h-5 px-2"
>
</Button>

View File

@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart";
export { PivotContextMenu } from "./ContextMenu";