lhj #352
|
|
@ -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 && (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
|
|||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue