2026-01-08 17:05:27 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PivotGrid 메인 컴포넌트
|
|
|
|
|
* 다차원 데이터 분석을 위한 피벗 테이블
|
|
|
|
|
*/
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
2026-01-08 17:05:27 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import {
|
|
|
|
|
PivotGridProps,
|
|
|
|
|
PivotResult,
|
|
|
|
|
PivotFieldConfig,
|
|
|
|
|
PivotCellData,
|
|
|
|
|
PivotFlatRow,
|
|
|
|
|
PivotCellValue,
|
|
|
|
|
PivotGridState,
|
|
|
|
|
} from "./types";
|
|
|
|
|
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
2026-01-09 11:51:35 +09:00
|
|
|
import { exportPivotToExcel } from "./utils/exportExcel";
|
|
|
|
|
import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat";
|
|
|
|
|
import { FieldPanel } from "./components/FieldPanel";
|
|
|
|
|
import { FieldChooser } from "./components/FieldChooser";
|
|
|
|
|
import { DrillDownModal } from "./components/DrillDownModal";
|
|
|
|
|
import { PivotChart } from "./components/PivotChart";
|
2026-01-09 14:41:27 +09:00
|
|
|
import { FilterPopup } from "./components/FilterPopup";
|
|
|
|
|
import { useVirtualScroll } from "./hooks/useVirtualScroll";
|
2026-01-08 17:05:27 +09:00
|
|
|
import {
|
|
|
|
|
ChevronRight,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
Download,
|
|
|
|
|
Settings,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Maximize2,
|
|
|
|
|
Minimize2,
|
2026-01-09 11:51:35 +09:00
|
|
|
LayoutGrid,
|
|
|
|
|
FileSpreadsheet,
|
|
|
|
|
BarChart3,
|
2026-01-09 14:41:27 +09:00
|
|
|
Filter,
|
|
|
|
|
ArrowUp,
|
|
|
|
|
ArrowDown,
|
|
|
|
|
ArrowUpDown,
|
2026-01-09 15:11:30 +09:00
|
|
|
Printer,
|
|
|
|
|
Save,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
FileText,
|
|
|
|
|
Loader2,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
2026-01-08 17:05:27 +09:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// ==================== 유틸리티 함수 ====================
|
|
|
|
|
|
|
|
|
|
// 셀 병합 정보 계산
|
|
|
|
|
interface MergeCellInfo {
|
|
|
|
|
rowSpan: number;
|
|
|
|
|
skip: boolean; // 병합된 셀에서 건너뛸지 여부
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const calculateMergeCells = (
|
|
|
|
|
rows: PivotFlatRow[],
|
|
|
|
|
mergeCells: boolean
|
|
|
|
|
): Map<number, MergeCellInfo> => {
|
|
|
|
|
const mergeInfo = new Map<number, MergeCellInfo>();
|
|
|
|
|
|
|
|
|
|
if (!mergeCells || rows.length === 0) {
|
|
|
|
|
rows.forEach((_, idx) => mergeInfo.set(idx, { rowSpan: 1, skip: false }));
|
|
|
|
|
return mergeInfo;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
while (i < rows.length) {
|
|
|
|
|
const currentPath = rows[i].path.join("|||");
|
|
|
|
|
let spanCount = 1;
|
|
|
|
|
|
|
|
|
|
// 같은 path를 가진 연속 행 찾기
|
|
|
|
|
while (
|
|
|
|
|
i + spanCount < rows.length &&
|
|
|
|
|
rows[i + spanCount].path.join("|||") === currentPath
|
|
|
|
|
) {
|
|
|
|
|
spanCount++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 첫 번째 행은 rowSpan 설정
|
|
|
|
|
mergeInfo.set(i, { rowSpan: spanCount, skip: false });
|
|
|
|
|
|
|
|
|
|
// 나머지 행은 skip
|
|
|
|
|
for (let j = 1; j < spanCount; j++) {
|
|
|
|
|
mergeInfo.set(i + j, { rowSpan: 1, skip: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
i += spanCount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mergeInfo;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// ==================== 서브 컴포넌트 ====================
|
|
|
|
|
|
|
|
|
|
// 행 헤더 셀
|
|
|
|
|
interface RowHeaderCellProps {
|
|
|
|
|
row: PivotFlatRow;
|
|
|
|
|
rowFields: PivotFieldConfig[];
|
|
|
|
|
onToggleExpand: (path: string[]) => void;
|
2026-01-09 15:11:30 +09:00
|
|
|
rowSpan?: number;
|
2026-01-08 17:05:27 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
|
|
|
|
|
row,
|
|
|
|
|
rowFields,
|
|
|
|
|
onToggleExpand,
|
2026-01-09 15:11:30 +09:00
|
|
|
rowSpan = 1,
|
2026-01-08 17:05:27 +09:00
|
|
|
}) => {
|
|
|
|
|
const indentSize = row.level * 20;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<td
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-r border-b border-border bg-muted/50",
|
|
|
|
|
"px-2 py-1.5 text-left text-sm",
|
|
|
|
|
"whitespace-nowrap font-medium",
|
|
|
|
|
row.isExpanded && "bg-muted/70"
|
|
|
|
|
)}
|
|
|
|
|
style={{ paddingLeft: `${8 + indentSize}px` }}
|
2026-01-09 15:11:30 +09:00
|
|
|
rowSpan={rowSpan > 1 ? rowSpan : undefined}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{row.hasChildren && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onToggleExpand(row.path)}
|
|
|
|
|
className="p-0.5 hover:bg-accent rounded"
|
|
|
|
|
>
|
|
|
|
|
{row.isExpanded ? (
|
|
|
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{!row.hasChildren && <span className="w-4" />}
|
|
|
|
|
<span>{row.caption}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 데이터 셀
|
|
|
|
|
interface DataCellProps {
|
|
|
|
|
values: PivotCellValue[];
|
|
|
|
|
isTotal?: boolean;
|
2026-01-09 14:41:27 +09:00
|
|
|
isSelected?: boolean;
|
2026-01-09 15:11:30 +09:00
|
|
|
onClick?: (e?: React.MouseEvent) => void;
|
2026-01-09 11:51:35 +09:00
|
|
|
onDoubleClick?: () => void;
|
|
|
|
|
conditionalStyle?: CellFormatStyle;
|
2026-01-08 17:05:27 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DataCell: React.FC<DataCellProps> = ({
|
|
|
|
|
values,
|
|
|
|
|
isTotal = false,
|
2026-01-09 14:41:27 +09:00
|
|
|
isSelected = false,
|
2026-01-08 17:05:27 +09:00
|
|
|
onClick,
|
2026-01-09 11:51:35 +09:00
|
|
|
onDoubleClick,
|
|
|
|
|
conditionalStyle,
|
2026-01-08 17:05:27 +09:00
|
|
|
}) => {
|
2026-01-09 11:51:35 +09:00
|
|
|
// 조건부 서식 스타일 계산
|
|
|
|
|
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
|
|
|
|
|
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
|
|
|
|
|
const icon = conditionalStyle?.icon;
|
2026-01-09 14:41:27 +09:00
|
|
|
|
|
|
|
|
// 선택 상태 스타일
|
|
|
|
|
const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10";
|
2026-01-09 11:51:35 +09:00
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
if (!values || values.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<td
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-r border-b border-border",
|
|
|
|
|
"px-2 py-1.5 text-right text-sm",
|
2026-01-09 14:41:27 +09:00
|
|
|
isTotal && "bg-primary/5 font-medium",
|
|
|
|
|
selectedClass
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
2026-01-09 11:51:35 +09:00
|
|
|
style={cellStyle}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
onDoubleClick={onDoubleClick}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
2026-01-16 14:03:07 +09:00
|
|
|
0
|
2026-01-08 17:05:27 +09:00
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// 툴팁 내용 생성
|
|
|
|
|
const tooltipContent = values.map((v) =>
|
|
|
|
|
`${v.field || "값"}: ${v.formattedValue || v.value}`
|
|
|
|
|
).join("\n");
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// 단일 데이터 필드인 경우
|
|
|
|
|
if (values.length === 1) {
|
|
|
|
|
return (
|
|
|
|
|
<td
|
|
|
|
|
className={cn(
|
2026-01-09 15:11:30 +09:00
|
|
|
"border-r border-b border-border relative group/cell",
|
2026-01-08 17:05:27 +09:00
|
|
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
|
|
|
|
isTotal && "bg-primary/5 font-medium",
|
2026-01-09 14:41:27 +09:00
|
|
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50",
|
|
|
|
|
selectedClass
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
2026-01-09 11:51:35 +09:00
|
|
|
style={cellStyle}
|
2026-01-08 17:05:27 +09:00
|
|
|
onClick={onClick}
|
2026-01-09 11:51:35 +09:00
|
|
|
onDoubleClick={onDoubleClick}
|
2026-01-09 15:11:30 +09:00
|
|
|
title={tooltipContent}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
2026-01-09 11:51:35 +09:00
|
|
|
{/* Data Bar */}
|
|
|
|
|
{hasDataBar && (
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-y-0 left-0 opacity-30"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${conditionalStyle?.dataBarWidth}%`,
|
|
|
|
|
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<span className="relative z-10 flex items-center justify-end gap-1">
|
|
|
|
|
{icon && <span>{icon}</span>}
|
2026-01-16 14:03:07 +09:00
|
|
|
{values[0].formattedValue || (values[0].value === 0 ? '0' : values[0].formattedValue)}
|
2026-01-09 11:51:35 +09:00
|
|
|
</span>
|
2026-01-08 17:05:27 +09:00
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 다중 데이터 필드인 경우
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{values.map((val, idx) => (
|
|
|
|
|
<td
|
|
|
|
|
key={idx}
|
|
|
|
|
className={cn(
|
2026-01-09 11:51:35 +09:00
|
|
|
"border-r border-b border-border relative",
|
2026-01-08 17:05:27 +09:00
|
|
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
|
|
|
|
isTotal && "bg-primary/5 font-medium",
|
2026-01-09 14:41:27 +09:00
|
|
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50",
|
|
|
|
|
selectedClass
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
2026-01-09 11:51:35 +09:00
|
|
|
style={cellStyle}
|
2026-01-08 17:05:27 +09:00
|
|
|
onClick={onClick}
|
2026-01-09 11:51:35 +09:00
|
|
|
onDoubleClick={onDoubleClick}
|
2026-01-09 15:11:30 +09:00
|
|
|
title={`${val.field || "값"}: ${val.formattedValue || val.value}`}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
2026-01-09 11:51:35 +09:00
|
|
|
{hasDataBar && (
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-y-0 left-0 opacity-30"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${conditionalStyle?.dataBarWidth}%`,
|
|
|
|
|
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<span className="relative z-10 flex items-center justify-end gap-1">
|
|
|
|
|
{icon && <span>{icon}</span>}
|
2026-01-16 14:03:07 +09:00
|
|
|
{val.formattedValue || (val.value === 0 ? '0' : val.formattedValue)}
|
2026-01-09 11:51:35 +09:00
|
|
|
</span>
|
2026-01-08 17:05:27 +09:00
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ==================== 메인 컴포넌트 ====================
|
|
|
|
|
|
|
|
|
|
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|
|
|
|
title,
|
2026-01-09 11:51:35 +09:00
|
|
|
fields: initialFields = [],
|
2026-01-08 17:05:27 +09:00
|
|
|
totals = {
|
|
|
|
|
showRowGrandTotals: true,
|
|
|
|
|
showColumnGrandTotals: true,
|
|
|
|
|
showRowTotals: true,
|
|
|
|
|
showColumnTotals: true,
|
|
|
|
|
},
|
|
|
|
|
style = {
|
|
|
|
|
theme: "default",
|
|
|
|
|
headerStyle: "default",
|
|
|
|
|
cellPadding: "normal",
|
|
|
|
|
borderStyle: "light",
|
|
|
|
|
alternateRowColors: true,
|
|
|
|
|
highlightTotals: true,
|
|
|
|
|
},
|
2026-01-09 11:51:35 +09:00
|
|
|
fieldChooser,
|
|
|
|
|
chart: chartConfig,
|
2026-01-08 17:05:27 +09:00
|
|
|
allowExpandAll = true,
|
|
|
|
|
height = "auto",
|
|
|
|
|
maxHeight,
|
|
|
|
|
exportConfig,
|
|
|
|
|
data: externalData,
|
|
|
|
|
onCellClick,
|
2026-01-09 11:51:35 +09:00
|
|
|
onCellDoubleClick,
|
|
|
|
|
onFieldDrop,
|
2026-01-08 17:05:27 +09:00
|
|
|
onExpandChange,
|
|
|
|
|
}) => {
|
2026-01-09 11:51:35 +09:00
|
|
|
// 디버깅 로그
|
|
|
|
|
console.log("🔶 PivotGridComponent props:", {
|
|
|
|
|
title,
|
|
|
|
|
hasExternalData: !!externalData,
|
|
|
|
|
externalDataLength: externalData?.length,
|
|
|
|
|
initialFieldsLength: initialFields?.length,
|
|
|
|
|
});
|
2026-01-16 10:18:11 +09:00
|
|
|
|
|
|
|
|
// 🆕 데이터 샘플 확인
|
|
|
|
|
if (externalData && externalData.length > 0) {
|
|
|
|
|
console.log("🔶 첫 번째 데이터 샘플:", externalData[0]);
|
|
|
|
|
console.log("🔶 전체 데이터 개수:", externalData.length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🆕 필드 설정 확인
|
|
|
|
|
if (initialFields && initialFields.length > 0) {
|
|
|
|
|
console.log("🔶 필드 설정:", initialFields);
|
|
|
|
|
}
|
2026-01-08 17:05:27 +09:00
|
|
|
// ==================== 상태 ====================
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
|
2026-01-08 17:05:27 +09:00
|
|
|
const [pivotState, setPivotState] = useState<PivotGridState>({
|
|
|
|
|
expandedRowPaths: [],
|
|
|
|
|
expandedColumnPaths: [],
|
|
|
|
|
sortConfig: null,
|
|
|
|
|
filterConfig: {},
|
|
|
|
|
});
|
2026-01-16 10:18:11 +09:00
|
|
|
|
|
|
|
|
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
|
|
|
|
|
const [isInitialExpanded, setIsInitialExpanded] = useState(false);
|
2026-01-08 17:05:27 +09:00
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
2026-01-09 14:41:27 +09:00
|
|
|
const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태
|
2026-01-09 11:51:35 +09:00
|
|
|
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);
|
2026-01-09 14:41:27 +09:00
|
|
|
const [containerHeight, setContainerHeight] = useState(400);
|
|
|
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// 셀 선택 상태 (범위 선택 지원)
|
2026-01-09 14:41:27 +09:00
|
|
|
const [selectedCell, setSelectedCell] = useState<{
|
|
|
|
|
rowIndex: number;
|
|
|
|
|
colIndex: number;
|
|
|
|
|
} | null>(null);
|
2026-01-09 15:11:30 +09:00
|
|
|
const [selectionRange, setSelectionRange] = useState<{
|
|
|
|
|
startRow: number;
|
|
|
|
|
startCol: number;
|
|
|
|
|
endRow: number;
|
|
|
|
|
endCol: number;
|
|
|
|
|
} | null>(null);
|
2026-01-09 14:41:27 +09:00
|
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
|
|
|
|
|
|
|
|
// 정렬 상태
|
|
|
|
|
const [sortConfig, setSortConfig] = useState<{
|
|
|
|
|
field: string;
|
|
|
|
|
direction: "asc" | "desc";
|
|
|
|
|
} | null>(null);
|
2026-01-09 15:11:30 +09:00
|
|
|
|
|
|
|
|
// 열 너비 상태
|
|
|
|
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
|
|
|
|
|
const [resizingColumn, setResizingColumn] = useState<number | null>(null);
|
|
|
|
|
const [resizeStartX, setResizeStartX] = useState<number>(0);
|
|
|
|
|
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
|
2026-01-09 11:51:35 +09:00
|
|
|
|
|
|
|
|
// 외부 fields 변경 시 동기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (initialFields.length > 0) {
|
|
|
|
|
setFields(initialFields);
|
|
|
|
|
}
|
|
|
|
|
}, [initialFields]);
|
2026-01-08 17:05:27 +09:00
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// 상태 저장 키
|
|
|
|
|
const stateStorageKey = `pivot-state-${title || "default"}`;
|
|
|
|
|
|
|
|
|
|
// 상태 저장 (localStorage)
|
|
|
|
|
const saveStateToStorage = useCallback(() => {
|
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
|
const stateToSave = {
|
|
|
|
|
fields,
|
|
|
|
|
pivotState,
|
|
|
|
|
sortConfig,
|
|
|
|
|
columnWidths,
|
|
|
|
|
};
|
|
|
|
|
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
|
|
|
|
|
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
|
|
|
|
|
|
|
|
|
|
// 상태 복원 (localStorage)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
|
const savedState = localStorage.getItem(stateStorageKey);
|
|
|
|
|
if (savedState) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(savedState);
|
|
|
|
|
if (parsed.fields) setFields(parsed.fields);
|
|
|
|
|
if (parsed.pivotState) setPivotState(parsed.pivotState);
|
|
|
|
|
if (parsed.sortConfig) setSortConfig(parsed.sortConfig);
|
|
|
|
|
if (parsed.columnWidths) setColumnWidths(parsed.columnWidths);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("피벗 상태 복원 실패:", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [stateStorageKey]);
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// 데이터
|
|
|
|
|
const data = externalData || [];
|
|
|
|
|
|
|
|
|
|
// ==================== 필드 분류 ====================
|
|
|
|
|
|
|
|
|
|
const rowFields = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
fields
|
|
|
|
|
.filter((f) => f.area === "row" && f.visible !== false)
|
|
|
|
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
|
|
|
|
[fields]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const columnFields = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
fields
|
|
|
|
|
.filter((f) => f.area === "column" && f.visible !== false)
|
|
|
|
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
|
|
|
|
[fields]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const dataFields = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
fields
|
|
|
|
|
.filter((f) => f.area === "data" && f.visible !== false)
|
|
|
|
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
|
|
|
|
[fields]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
// 필터 영역 필드
|
2026-01-09 11:51:35 +09:00
|
|
|
const filterFields = useMemo(
|
2026-01-16 15:17:49 +09:00
|
|
|
() => {
|
|
|
|
|
const result = fields
|
2026-01-09 11:51:35 +09:00
|
|
|
.filter((f) => f.area === "filter" && f.visible !== false)
|
2026-01-16 15:17:49 +09:00
|
|
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
|
|
|
|
|
|
|
|
|
console.log("🔷 [filterFields] 필터 필드 계산:", {
|
|
|
|
|
totalFields: fields.length,
|
|
|
|
|
filterFieldsCount: result.length,
|
|
|
|
|
filterFieldNames: result.map(f => f.field),
|
|
|
|
|
allFieldAreas: fields.map(f => ({ field: f.field, area: f.area, visible: f.visible })),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
},
|
2026-01-09 11:51:35 +09:00
|
|
|
[fields]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 사용 가능한 필드 목록 (FieldChooser용)
|
|
|
|
|
const availableFields = useMemo(() => {
|
|
|
|
|
if (data.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
const sampleRow = data[0];
|
|
|
|
|
return Object.keys(sampleRow).map((key) => {
|
|
|
|
|
const existingField = fields.find((f) => f.field === key);
|
|
|
|
|
const value = sampleRow[key];
|
|
|
|
|
|
|
|
|
|
// 데이터 타입 추론
|
|
|
|
|
let dataType: "string" | "number" | "date" | "boolean" = "string";
|
|
|
|
|
if (typeof value === "number") dataType = "number";
|
|
|
|
|
else if (typeof value === "boolean") dataType = "boolean";
|
|
|
|
|
else if (value instanceof Date) dataType = "date";
|
|
|
|
|
else if (typeof value === "string") {
|
|
|
|
|
// 날짜 문자열 감지
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
field: key,
|
|
|
|
|
caption: existingField?.caption || key,
|
|
|
|
|
dataType,
|
|
|
|
|
isSelected: existingField?.visible !== false,
|
|
|
|
|
currentArea: existingField?.area,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}, [data, fields]);
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
// ==================== 필터 적용 ====================
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// ==================== 피벗 처리 ====================
|
|
|
|
|
|
|
|
|
|
const pivotResult = useMemo<PivotResult | null>(() => {
|
2026-01-09 14:41:27 +09:00
|
|
|
if (!filteredData || filteredData.length === 0 || fields.length === 0) {
|
2026-01-08 17:05:27 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
const visibleFields = fields.filter((f) => f.visible !== false);
|
2026-01-09 14:41:27 +09:00
|
|
|
// 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외)
|
|
|
|
|
if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) {
|
2026-01-09 11:51:35 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 10:18:11 +09:00
|
|
|
const result = processPivotData(
|
2026-01-09 14:41:27 +09:00
|
|
|
filteredData,
|
2026-01-09 11:51:35 +09:00
|
|
|
visibleFields,
|
2026-01-08 17:05:27 +09:00
|
|
|
pivotState.expandedRowPaths,
|
|
|
|
|
pivotState.expandedColumnPaths
|
|
|
|
|
);
|
2026-01-16 10:18:11 +09:00
|
|
|
|
|
|
|
|
// 🆕 피벗 결과 확인
|
|
|
|
|
console.log("🔶 피벗 처리 결과:", {
|
|
|
|
|
hasResult: !!result,
|
|
|
|
|
flatRowsCount: result?.flatRows?.length,
|
|
|
|
|
flatColumnsCount: result?.flatColumns?.length,
|
|
|
|
|
dataMatrixSize: result?.dataMatrix?.size,
|
|
|
|
|
expandedRowPaths: pivotState.expandedRowPaths.length,
|
|
|
|
|
expandedColumnPaths: pivotState.expandedColumnPaths.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
2026-01-16 14:29:19 +09:00
|
|
|
}, [
|
|
|
|
|
filteredData,
|
|
|
|
|
fields,
|
|
|
|
|
JSON.stringify(pivotState.expandedRowPaths),
|
|
|
|
|
JSON.stringify(pivotState.expandedColumnPaths)
|
|
|
|
|
]);
|
2026-01-16 10:18:11 +09:00
|
|
|
|
|
|
|
|
// 🆕 초기 로드 시 첫 레벨 자동 확장
|
|
|
|
|
useEffect(() => {
|
2026-01-16 14:29:19 +09:00
|
|
|
if (pivotResult && pivotResult.flatRows.length > 0 && !isInitialExpanded) {
|
2026-01-16 14:03:07 +09:00
|
|
|
console.log("🔶 피벗 결과 생성됨:", {
|
|
|
|
|
flatRowsCount: pivotResult.flatRows.length,
|
|
|
|
|
expandedRowPaths: pivotState.expandedRowPaths.length,
|
|
|
|
|
isInitialExpanded,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-16 10:18:11 +09:00
|
|
|
// 첫 레벨 행들의 경로 수집 (level 0인 행들)
|
2026-01-16 14:03:07 +09:00
|
|
|
const firstLevelRows = pivotResult.flatRows.filter(row => row.level === 0 && row.hasChildren);
|
2026-01-16 10:18:11 +09:00
|
|
|
|
2026-01-16 14:03:07 +09:00
|
|
|
console.log("🔶 첫 레벨 행 (level 0, hasChildren):", firstLevelRows.map(r => ({ path: r.path, caption: r.caption })));
|
|
|
|
|
|
2026-01-16 14:29:19 +09:00
|
|
|
// 첫 레벨 행이 있으면 자동 확장
|
|
|
|
|
if (firstLevelRows.length > 0) {
|
2026-01-16 14:03:07 +09:00
|
|
|
const firstLevelPaths = firstLevelRows.map(row => row.path);
|
2026-01-16 14:29:19 +09:00
|
|
|
console.log("🔶 초기 자동 확장 실행 (한 번만):", firstLevelPaths);
|
2026-01-16 10:18:11 +09:00
|
|
|
setPivotState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
expandedRowPaths: firstLevelPaths,
|
|
|
|
|
}));
|
|
|
|
|
setIsInitialExpanded(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 14:29:19 +09:00
|
|
|
}, [pivotResult, isInitialExpanded]);
|
2026-01-08 17:05:27 +09:00
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
// 조건부 서식용 전체 값 수집
|
|
|
|
|
const allCellValues = useMemo(() => {
|
|
|
|
|
if (!pivotResult) return new Map<string, number[]>();
|
|
|
|
|
|
|
|
|
|
const valuesByField = new Map<string, number[]>();
|
|
|
|
|
|
|
|
|
|
// 데이터 매트릭스에서 모든 값 수집
|
|
|
|
|
pivotResult.dataMatrix.forEach((values) => {
|
|
|
|
|
values.forEach((val) => {
|
|
|
|
|
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
|
|
|
|
const existing = valuesByField.get(val.field) || [];
|
|
|
|
|
existing.push(val.value);
|
|
|
|
|
valuesByField.set(val.field, existing);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 행 총계 값 수집
|
|
|
|
|
pivotResult.grandTotals.row.forEach((values) => {
|
|
|
|
|
values.forEach((val) => {
|
|
|
|
|
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
|
|
|
|
const existing = valuesByField.get(val.field) || [];
|
|
|
|
|
existing.push(val.value);
|
|
|
|
|
valuesByField.set(val.field, existing);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 열 총계 값 수집
|
|
|
|
|
pivotResult.grandTotals.column.forEach((values) => {
|
|
|
|
|
values.forEach((val) => {
|
|
|
|
|
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
|
|
|
|
const existing = valuesByField.get(val.field) || [];
|
|
|
|
|
existing.push(val.value);
|
|
|
|
|
valuesByField.set(val.field, existing);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return valuesByField;
|
|
|
|
|
}, [pivotResult]);
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
// ==================== 가상 스크롤 ====================
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// 열 크기 조절 중
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (resizingColumn === null) return;
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
|
|
|
const diff = e.clientX - resizeStartX;
|
|
|
|
|
const newWidth = Math.max(50, resizeStartWidth + diff); // 최소 50px
|
|
|
|
|
setColumnWidths((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[resizingColumn]: newWidth,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
|
setResizingColumn(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
};
|
|
|
|
|
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
// 가상 스크롤 훅 사용
|
|
|
|
|
const flatRows = pivotResult?.flatRows || [];
|
2026-01-09 15:11:30 +09:00
|
|
|
|
|
|
|
|
// 정렬된 행 데이터
|
|
|
|
|
const sortedFlatRows = useMemo(() => {
|
|
|
|
|
if (!sortConfig || !pivotResult) return flatRows;
|
|
|
|
|
|
|
|
|
|
const { field, direction } = sortConfig;
|
|
|
|
|
const { dataMatrix, flatColumns } = pivotResult;
|
|
|
|
|
|
|
|
|
|
// 각 행의 정렬 기준 값 계산
|
|
|
|
|
const rowsWithSortValue = flatRows.map((row) => {
|
|
|
|
|
let sortValue = 0;
|
|
|
|
|
// 모든 열에 대해 해당 필드의 합계 계산
|
|
|
|
|
flatColumns.forEach((col) => {
|
|
|
|
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
|
|
|
|
const values = dataMatrix.get(cellKey) || [];
|
|
|
|
|
const targetValue = values.find((v) => v.field === field);
|
|
|
|
|
if (targetValue?.value != null) {
|
|
|
|
|
sortValue += targetValue.value;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return { row, sortValue };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 정렬
|
|
|
|
|
rowsWithSortValue.sort((a, b) => {
|
|
|
|
|
if (direction === "asc") {
|
|
|
|
|
return a.sortValue - b.sortValue;
|
|
|
|
|
}
|
|
|
|
|
return b.sortValue - a.sortValue;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return rowsWithSortValue.map((item) => item.row);
|
|
|
|
|
}, [flatRows, sortConfig, pivotResult]);
|
|
|
|
|
|
|
|
|
|
const enableVirtualScroll = sortedFlatRows.length > VIRTUAL_SCROLL_THRESHOLD;
|
2026-01-09 14:41:27 +09:00
|
|
|
|
|
|
|
|
const virtualScroll = useVirtualScroll({
|
2026-01-09 15:11:30 +09:00
|
|
|
itemCount: sortedFlatRows.length,
|
2026-01-09 14:41:27 +09:00
|
|
|
itemHeight: ROW_HEIGHT,
|
|
|
|
|
containerHeight: containerHeight,
|
|
|
|
|
overscan: 10,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 가상 스크롤 적용된 행 데이터
|
|
|
|
|
const visibleFlatRows = useMemo(() => {
|
2026-01-09 15:11:30 +09:00
|
|
|
if (!enableVirtualScroll) return sortedFlatRows;
|
|
|
|
|
return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1);
|
|
|
|
|
}, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]);
|
2026-01-09 14:41:27 +09:00
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
// 조건부 서식 스타일 계산 헬퍼
|
|
|
|
|
const getCellConditionalStyle = useCallback(
|
|
|
|
|
(value: number | undefined, field: string): CellFormatStyle => {
|
|
|
|
|
if (!style?.conditionalFormats || style.conditionalFormats.length === 0) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
const allValues = allCellValues.get(field) || [];
|
|
|
|
|
return getConditionalStyle(value, field, style.conditionalFormats, allValues);
|
|
|
|
|
},
|
|
|
|
|
[style?.conditionalFormats, allCellValues]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// ==================== 이벤트 핸들러 ====================
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
// 필드 변경
|
|
|
|
|
const handleFieldsChange = useCallback(
|
|
|
|
|
(newFields: PivotFieldConfig[]) => {
|
2026-01-16 15:17:49 +09:00
|
|
|
console.log("🔷 [handleFieldsChange] 필드 변경:", {
|
|
|
|
|
totalFields: newFields.length,
|
|
|
|
|
filterFields: newFields.filter(f => f.area === "filter").length,
|
|
|
|
|
filterFieldNames: newFields.filter(f => f.area === "filter").map(f => f.field),
|
|
|
|
|
changedFields: newFields.filter(f => f.area === "filter"),
|
|
|
|
|
});
|
|
|
|
|
console.log("🔷 [handleFieldsChange] setFields 호출 전");
|
2026-01-09 11:51:35 +09:00
|
|
|
setFields(newFields);
|
2026-01-16 15:17:49 +09:00
|
|
|
console.log("🔷 [handleFieldsChange] setFields 호출 후");
|
2026-01-09 11:51:35 +09:00
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// 행 확장/축소
|
|
|
|
|
const handleToggleRowExpand = useCallback(
|
|
|
|
|
(path: string[]) => {
|
2026-01-16 10:18:11 +09:00
|
|
|
console.log("🔶 행 확장/축소 클릭:", path);
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
setPivotState((prev) => {
|
|
|
|
|
const pathKey = pathToKey(path);
|
|
|
|
|
const existingIndex = prev.expandedRowPaths.findIndex(
|
|
|
|
|
(p) => pathToKey(p) === pathKey
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let newPaths: string[][];
|
|
|
|
|
if (existingIndex >= 0) {
|
2026-01-16 10:18:11 +09:00
|
|
|
console.log("🔶 행 축소:", path);
|
2026-01-08 17:05:27 +09:00
|
|
|
newPaths = prev.expandedRowPaths.filter(
|
|
|
|
|
(_, i) => i !== existingIndex
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2026-01-16 10:18:11 +09:00
|
|
|
console.log("🔶 행 확장:", path);
|
2026-01-08 17:05:27 +09:00
|
|
|
newPaths = [...prev.expandedRowPaths, path];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 10:18:11 +09:00
|
|
|
console.log("🔶 새로운 확장 경로:", newPaths);
|
2026-01-08 17:05:27 +09:00
|
|
|
onExpandChange?.(newPaths);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
|
|
|
|
expandedRowPaths: newPaths,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[onExpandChange]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-16 14:29:19 +09:00
|
|
|
// 전체 확장 (재귀적으로 모든 레벨 확장)
|
2026-01-08 17:05:27 +09:00
|
|
|
const handleExpandAll = useCallback(() => {
|
2026-01-16 14:29:19 +09:00
|
|
|
if (!pivotResult) {
|
|
|
|
|
console.log("❌ [handleExpandAll] pivotResult가 없음");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-08 17:05:27 +09:00
|
|
|
|
2026-01-16 14:29:19 +09:00
|
|
|
// 🆕 재귀적으로 모든 가능한 경로 생성
|
2026-01-08 17:05:27 +09:00
|
|
|
const allRowPaths: string[][] = [];
|
2026-01-16 14:29:19 +09:00
|
|
|
const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false);
|
|
|
|
|
|
|
|
|
|
// 데이터에서 모든 고유한 경로 추출
|
|
|
|
|
const pathSet = new Set<string>();
|
|
|
|
|
filteredData.forEach((item) => {
|
|
|
|
|
for (let depth = 1; depth <= rowFields.length; depth++) {
|
|
|
|
|
const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? ""));
|
|
|
|
|
const pathKey = JSON.stringify(path);
|
|
|
|
|
pathSet.add(pathKey);
|
2026-01-08 17:05:27 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-16 14:29:19 +09:00
|
|
|
// Set을 배열로 변환
|
|
|
|
|
pathSet.forEach((pathKey) => {
|
|
|
|
|
allRowPaths.push(JSON.parse(pathKey));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log("🔷 [handleExpandAll] 확장할 행:", {
|
|
|
|
|
totalRows: pivotResult.flatRows.length,
|
|
|
|
|
rowsWithChildren: allRowPaths.length,
|
|
|
|
|
paths: allRowPaths.slice(0, 5), // 처음 5개만 로그
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
setPivotState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
expandedRowPaths: allRowPaths,
|
|
|
|
|
expandedColumnPaths: [],
|
|
|
|
|
}));
|
2026-01-16 14:29:19 +09:00
|
|
|
}, [pivotResult, fields, filteredData]);
|
2026-01-08 17:05:27 +09:00
|
|
|
|
|
|
|
|
// 전체 축소
|
|
|
|
|
const handleCollapseAll = useCallback(() => {
|
2026-01-16 14:29:19 +09:00
|
|
|
console.log("🔷 [handleCollapseAll] 전체 축소 실행");
|
|
|
|
|
|
|
|
|
|
setPivotState((prev) => {
|
|
|
|
|
console.log("🔷 [handleCollapseAll] 이전 상태:", {
|
|
|
|
|
expandedRowPaths: prev.expandedRowPaths.length,
|
|
|
|
|
expandedColumnPaths: prev.expandedColumnPaths.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
|
|
|
|
expandedRowPaths: [],
|
|
|
|
|
expandedColumnPaths: [],
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-01-08 17:05:27 +09:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 셀 클릭
|
|
|
|
|
const handleCellClick = useCallback(
|
|
|
|
|
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
|
|
|
|
|
if (!onCellClick) return;
|
|
|
|
|
|
|
|
|
|
const cellData: PivotCellData = {
|
|
|
|
|
value: values[0]?.value,
|
|
|
|
|
rowPath,
|
|
|
|
|
columnPath: colPath,
|
|
|
|
|
field: values[0]?.field,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onCellClick(cellData);
|
|
|
|
|
},
|
|
|
|
|
[onCellClick]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
// 셀 더블클릭 (Drill Down)
|
|
|
|
|
const handleCellDoubleClick = useCallback(
|
|
|
|
|
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
|
|
|
|
|
const cellData: PivotCellData = {
|
|
|
|
|
value: values[0]?.value,
|
|
|
|
|
rowPath,
|
|
|
|
|
columnPath: colPath,
|
|
|
|
|
field: values[0]?.field,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Drill Down 모달 열기
|
|
|
|
|
setDrillDownData({ open: true, cellData });
|
|
|
|
|
|
|
|
|
|
// 외부 콜백 호출
|
|
|
|
|
if (onCellDoubleClick) {
|
|
|
|
|
onCellDoubleClick(cellData);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[onCellDoubleClick]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// CSV 내보내기
|
|
|
|
|
const handleExportCSV = useCallback(() => {
|
|
|
|
|
if (!pivotResult) return;
|
|
|
|
|
|
|
|
|
|
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
|
|
|
|
|
|
|
|
|
let csv = "";
|
|
|
|
|
|
|
|
|
|
// 헤더 행
|
|
|
|
|
const headerRow = [""].concat(
|
|
|
|
|
flatColumns.map((col) => col.caption || "총계")
|
|
|
|
|
);
|
|
|
|
|
if (totals?.showRowGrandTotals) {
|
|
|
|
|
headerRow.push("총계");
|
|
|
|
|
}
|
|
|
|
|
csv += headerRow.join(",") + "\n";
|
|
|
|
|
|
|
|
|
|
// 데이터 행
|
|
|
|
|
flatRows.forEach((row) => {
|
|
|
|
|
const rowData = [row.caption];
|
|
|
|
|
|
|
|
|
|
flatColumns.forEach((col) => {
|
|
|
|
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
|
|
|
|
const values = dataMatrix.get(cellKey);
|
|
|
|
|
rowData.push(values?.[0]?.value?.toString() || "");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (totals?.showRowGrandTotals) {
|
|
|
|
|
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
|
|
|
|
rowData.push(rowTotal?.[0]?.value?.toString() || "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
csv += rowData.join(",") + "\n";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 열 총계 행
|
|
|
|
|
if (totals?.showColumnGrandTotals) {
|
|
|
|
|
const totalRow = ["총계"];
|
|
|
|
|
flatColumns.forEach((col) => {
|
|
|
|
|
const colTotal = grandTotals.column.get(pathToKey(col.path));
|
|
|
|
|
totalRow.push(colTotal?.[0]?.value?.toString() || "");
|
|
|
|
|
});
|
|
|
|
|
if (totals?.showRowGrandTotals) {
|
|
|
|
|
totalRow.push(grandTotals.grand[0]?.value?.toString() || "");
|
|
|
|
|
}
|
|
|
|
|
csv += totalRow.join(",") + "\n";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 다운로드
|
|
|
|
|
const blob = new Blob(["\uFEFF" + csv], {
|
|
|
|
|
type: "text/csv;charset=utf-8;",
|
|
|
|
|
});
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
link.href = URL.createObjectURL(blob);
|
|
|
|
|
link.download = `${title || "pivot"}_export.csv`;
|
|
|
|
|
link.click();
|
|
|
|
|
}, [pivotResult, totals, title]);
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
// Excel 내보내기
|
|
|
|
|
const handleExportExcel = useCallback(async () => {
|
|
|
|
|
if (!pivotResult) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await exportPivotToExcel(pivotResult, fields, totals, {
|
|
|
|
|
fileName: title || "pivot_export",
|
|
|
|
|
title: title,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Excel 내보내기 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}, [pivotResult, fields, totals, title]);
|
2026-01-09 15:11:30 +09:00
|
|
|
|
|
|
|
|
// 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함)
|
|
|
|
|
const handlePrint = useCallback(() => {
|
|
|
|
|
const printContent = tableRef.current;
|
|
|
|
|
if (!printContent) return;
|
|
|
|
|
|
|
|
|
|
const printWindow = window.open("", "_blank");
|
|
|
|
|
if (!printWindow) return;
|
|
|
|
|
|
|
|
|
|
printWindow.document.write(`
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>${title || "피벗 테이블"}</title>
|
|
|
|
|
<style>
|
|
|
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; }
|
|
|
|
|
h1 { font-size: 18px; margin-bottom: 16px; }
|
|
|
|
|
table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
|
|
|
|
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
|
|
|
|
|
th { background-color: #f5f5f5; font-weight: 600; }
|
|
|
|
|
td { text-align: right; }
|
|
|
|
|
td:first-child { text-align: left; font-weight: 500; }
|
|
|
|
|
tr:nth-child(even) { background-color: #fafafa; }
|
|
|
|
|
.total-row { background-color: #e8e8e8 !important; font-weight: 600; }
|
|
|
|
|
@media print {
|
|
|
|
|
body { padding: 0; }
|
|
|
|
|
table { page-break-inside: auto; }
|
|
|
|
|
tr { page-break-inside: avoid; }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<h1>${title || "피벗 테이블"}</h1>
|
|
|
|
|
${printContent.outerHTML}
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
printWindow.document.close();
|
|
|
|
|
printWindow.focus();
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
printWindow.print();
|
|
|
|
|
printWindow.close();
|
|
|
|
|
}, 250);
|
|
|
|
|
}, [title]);
|
|
|
|
|
|
|
|
|
|
// PDF 내보내기
|
|
|
|
|
const handleExportPDF = useCallback(async () => {
|
|
|
|
|
if (!pivotResult || !tableRef.current) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 동적 import로 jspdf와 html2canvas 로드
|
|
|
|
|
const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([
|
|
|
|
|
import("jspdf"),
|
|
|
|
|
import("html2canvas"),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const canvas = await html2canvas(tableRef.current, {
|
|
|
|
|
scale: 2,
|
|
|
|
|
useCORS: true,
|
|
|
|
|
logging: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const imgData = canvas.toDataURL("image/png");
|
|
|
|
|
const pdf = new jsPDF({
|
|
|
|
|
orientation: canvas.width > canvas.height ? "landscape" : "portrait",
|
|
|
|
|
unit: "px",
|
|
|
|
|
format: [canvas.width, canvas.height],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height);
|
|
|
|
|
pdf.save(`${title || "pivot"}_export.pdf`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("PDF 내보내기 실패:", error);
|
|
|
|
|
// jspdf가 없으면 인쇄 대화상자로 대체
|
|
|
|
|
handlePrint();
|
|
|
|
|
}
|
|
|
|
|
}, [pivotResult, title, handlePrint]);
|
|
|
|
|
|
|
|
|
|
// 데이터 새로고침
|
|
|
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
|
const handleRefreshData = useCallback(async () => {
|
|
|
|
|
setIsRefreshing(true);
|
|
|
|
|
// 외부 데이터 소스가 있으면 새로고침
|
|
|
|
|
// 여기서는 상태만 초기화
|
|
|
|
|
setPivotState({
|
|
|
|
|
expandedRowPaths: [],
|
|
|
|
|
expandedColumnPaths: [],
|
|
|
|
|
sortConfig: null,
|
|
|
|
|
filterConfig: {},
|
|
|
|
|
});
|
|
|
|
|
setSortConfig(null);
|
|
|
|
|
setSelectedCell(null);
|
|
|
|
|
setSelectionRange(null);
|
|
|
|
|
setTimeout(() => setIsRefreshing(false), 500);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 상태 저장 버튼 핸들러
|
|
|
|
|
const handleSaveState = useCallback(() => {
|
|
|
|
|
saveStateToStorage();
|
|
|
|
|
console.log("피벗 상태가 저장되었습니다.");
|
|
|
|
|
}, [saveStateToStorage]);
|
|
|
|
|
|
2026-01-16 15:17:49 +09:00
|
|
|
// 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지)
|
2026-01-09 15:11:30 +09:00
|
|
|
const handleResetState = useCallback(() => {
|
2026-01-16 15:17:49 +09:00
|
|
|
// 로컬 스토리지에서 상태 제거
|
2026-01-09 15:11:30 +09:00
|
|
|
localStorage.removeItem(stateStorageKey);
|
2026-01-16 15:17:49 +09:00
|
|
|
|
|
|
|
|
// 확장/축소, 정렬, 필터 상태만 초기화
|
2026-01-09 15:11:30 +09:00
|
|
|
setPivotState({
|
|
|
|
|
expandedRowPaths: [],
|
|
|
|
|
expandedColumnPaths: [],
|
|
|
|
|
sortConfig: null,
|
|
|
|
|
filterConfig: {},
|
|
|
|
|
});
|
|
|
|
|
setSortConfig(null);
|
|
|
|
|
setColumnWidths({});
|
|
|
|
|
setSelectedCell(null);
|
|
|
|
|
setSelectionRange(null);
|
2026-01-16 15:17:49 +09:00
|
|
|
|
|
|
|
|
// 🆕 필드 설정은 유지 (initialFields로 되돌리지 않음)
|
|
|
|
|
console.log("🔷 피벗 상태가 초기화되었습니다 (필드 설정은 유지)");
|
|
|
|
|
}, [stateStorageKey]);
|
2026-01-09 15:11:30 +09:00
|
|
|
|
|
|
|
|
// 필드 숨기기/표시 상태
|
|
|
|
|
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
|
|
|
|
|
|
|
|
|
|
const toggleFieldVisibility = useCallback((fieldName: string) => {
|
|
|
|
|
setHiddenFields((prev) => {
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
if (newSet.has(fieldName)) {
|
|
|
|
|
newSet.delete(fieldName);
|
|
|
|
|
} else {
|
|
|
|
|
newSet.add(fieldName);
|
|
|
|
|
}
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 숨겨진 필드 제외한 활성 필드들
|
|
|
|
|
const visibleFields = useMemo(() => {
|
|
|
|
|
return fields.filter((f) => !hiddenFields.has(f.field));
|
|
|
|
|
}, [fields, hiddenFields]);
|
|
|
|
|
|
|
|
|
|
// 숨겨진 필드 목록
|
|
|
|
|
const hiddenFieldsList = useMemo(() => {
|
|
|
|
|
return fields.filter((f) => hiddenFields.has(f.field));
|
|
|
|
|
}, [fields, hiddenFields]);
|
|
|
|
|
|
|
|
|
|
// 모든 필드 표시
|
|
|
|
|
const showAllFields = useCallback(() => {
|
|
|
|
|
setHiddenFields(new Set());
|
|
|
|
|
}, []);
|
2026-01-09 11:51:35 +09:00
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
// ==================== 렌더링 ====================
|
|
|
|
|
|
|
|
|
|
// 빈 상태
|
|
|
|
|
if (!data || data.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col items-center justify-center",
|
|
|
|
|
"p-8 text-center text-muted-foreground",
|
|
|
|
|
"border border-dashed border-border rounded-lg"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className="h-8 w-8 mb-2 opacity-50" />
|
|
|
|
|
<p className="text-sm">데이터가 없습니다</p>
|
|
|
|
|
<p className="text-xs mt-1">데이터를 로드하거나 필드를 설정해주세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
// 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인)
|
2026-01-09 11:51:35 +09:00
|
|
|
const hasActiveFields = fields.some(
|
2026-01-09 14:41:27 +09:00
|
|
|
(f) => f.visible !== false && ["row", "column", "data"].includes(f.area)
|
2026-01-09 11:51:35 +09:00
|
|
|
);
|
|
|
|
|
if (!hasActiveFields) {
|
2026-01-08 17:05:27 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-01-09 11:51:35 +09:00
|
|
|
"flex flex-col",
|
|
|
|
|
"border border-border rounded-lg overflow-hidden bg-background"
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
|
|
|
|
>
|
2026-01-09 11:51:35 +09:00
|
|
|
{/* 필드 패널 */}
|
|
|
|
|
<FieldPanel
|
|
|
|
|
fields={fields}
|
|
|
|
|
onFieldsChange={handleFieldsChange}
|
|
|
|
|
collapsed={!showFieldPanel}
|
|
|
|
|
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 안내 메시지 */}
|
|
|
|
|
<div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
|
|
|
|
|
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
|
|
|
|
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
|
|
|
|
<p className="text-xs mt-1">
|
|
|
|
|
행, 열, 데이터 영역에 필드를 배치해주세요
|
|
|
|
|
</p>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mt-4"
|
|
|
|
|
onClick={() => setShowFieldChooser(true)}
|
|
|
|
|
>
|
|
|
|
|
<LayoutGrid className="h-4 w-4 mr-2" />
|
|
|
|
|
필드 선택기 열기
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 필드 선택기 모달 */}
|
|
|
|
|
<FieldChooser
|
|
|
|
|
open={showFieldChooser}
|
|
|
|
|
onOpenChange={setShowFieldChooser}
|
|
|
|
|
availableFields={availableFields}
|
|
|
|
|
selectedFields={fields}
|
|
|
|
|
onFieldsChange={handleFieldsChange}
|
|
|
|
|
/>
|
2026-01-08 17:05:27 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 피벗 결과 없음
|
|
|
|
|
if (!pivotResult) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-center p-8">
|
|
|
|
|
<RefreshCw className="h-5 w-5 animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
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);
|
2026-01-09 15:11:30 +09:00
|
|
|
setSelectionRange(null);
|
2026-01-09 14:41:27 +09:00
|
|
|
break;
|
2026-01-09 15:11:30 +09:00
|
|
|
case "c":
|
|
|
|
|
// Ctrl+C: 클립보드 복사
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
copySelectionToClipboard();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
case "a":
|
|
|
|
|
// Ctrl+A: 전체 선택
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSelectionRange({
|
|
|
|
|
startRow: 0,
|
|
|
|
|
startCol: 0,
|
|
|
|
|
endRow: visibleFlatRows.length - 1,
|
|
|
|
|
endCol: flatColumns.length - 1,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return;
|
2026-01-09 14:41:27 +09:00
|
|
|
default:
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (newRowIndex !== rowIndex || newColIndex !== colIndex) {
|
|
|
|
|
setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// 셀 클릭으로 선택 (Shift+클릭으로 범위 선택)
|
|
|
|
|
const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => {
|
|
|
|
|
if (shiftKey && selectedCell) {
|
|
|
|
|
// Shift+클릭: 범위 선택
|
|
|
|
|
setSelectionRange({
|
|
|
|
|
startRow: Math.min(selectedCell.rowIndex, rowIndex),
|
|
|
|
|
startCol: Math.min(selectedCell.colIndex, colIndex),
|
|
|
|
|
endRow: Math.max(selectedCell.rowIndex, rowIndex),
|
|
|
|
|
endCol: Math.max(selectedCell.colIndex, colIndex),
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// 일반 클릭: 단일 선택
|
|
|
|
|
setSelectedCell({ rowIndex, colIndex });
|
|
|
|
|
setSelectionRange(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 셀이 선택 범위 내에 있는지 확인
|
|
|
|
|
const isCellInRange = (rowIndex: number, colIndex: number): boolean => {
|
|
|
|
|
if (selectionRange) {
|
|
|
|
|
return (
|
|
|
|
|
rowIndex >= selectionRange.startRow &&
|
|
|
|
|
rowIndex <= selectionRange.endRow &&
|
|
|
|
|
colIndex >= selectionRange.startCol &&
|
|
|
|
|
colIndex <= selectionRange.endCol
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (selectedCell) {
|
|
|
|
|
return selectedCell.rowIndex === rowIndex && selectedCell.colIndex === colIndex;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 열 크기 조절 시작
|
|
|
|
|
const handleResizeStart = (colIdx: number, e: React.MouseEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setResizingColumn(colIdx);
|
|
|
|
|
setResizeStartX(e.clientX);
|
|
|
|
|
setResizeStartWidth(columnWidths[colIdx] || 100);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 클립보드에 선택 영역 복사
|
|
|
|
|
const copySelectionToClipboard = () => {
|
|
|
|
|
const range = selectionRange || (selectedCell ? {
|
|
|
|
|
startRow: selectedCell.rowIndex,
|
|
|
|
|
startCol: selectedCell.colIndex,
|
|
|
|
|
endRow: selectedCell.rowIndex,
|
|
|
|
|
endCol: selectedCell.colIndex,
|
|
|
|
|
} : null);
|
|
|
|
|
|
|
|
|
|
if (!range) return;
|
|
|
|
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (let rowIdx = range.startRow; rowIdx <= range.endRow; rowIdx++) {
|
|
|
|
|
const row = visibleFlatRows[rowIdx];
|
|
|
|
|
if (!row) continue;
|
|
|
|
|
|
|
|
|
|
const rowValues: string[] = [];
|
|
|
|
|
for (let colIdx = range.startCol; colIdx <= range.endCol; colIdx++) {
|
|
|
|
|
const col = flatColumns[colIdx];
|
|
|
|
|
if (!col) continue;
|
|
|
|
|
|
|
|
|
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
|
|
|
|
const values = dataMatrix.get(cellKey) || [];
|
|
|
|
|
const cellValue = values.map((v) => v.formattedValue || v.value || "").join(", ");
|
|
|
|
|
rowValues.push(cellValue);
|
|
|
|
|
}
|
|
|
|
|
lines.push(rowValues.join("\t"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = lines.join("\n");
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
|
|
|
// 복사 성공 피드백 (선택적)
|
|
|
|
|
console.log("클립보드에 복사됨:", text);
|
|
|
|
|
}).catch((err) => {
|
|
|
|
|
console.error("클립보드 복사 실패:", err);
|
|
|
|
|
});
|
2026-01-09 14:41:27 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 정렬 토글
|
|
|
|
|
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" />;
|
|
|
|
|
};
|
2026-01-08 17:05:27 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col",
|
|
|
|
|
"border border-border rounded-lg overflow-hidden",
|
|
|
|
|
"bg-background",
|
|
|
|
|
isFullscreen && "fixed inset-4 z-50 shadow-2xl"
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
height: isFullscreen ? "auto" : height,
|
|
|
|
|
maxHeight: isFullscreen ? "none" : maxHeight,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-01-09 11:51:35 +09:00
|
|
|
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */}
|
|
|
|
|
<FieldPanel
|
|
|
|
|
fields={fields}
|
|
|
|
|
onFieldsChange={handleFieldsChange}
|
|
|
|
|
collapsed={!showFieldPanel}
|
|
|
|
|
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
{/* 헤더 툴바 */}
|
|
|
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
2026-01-09 14:41:27 +09:00
|
|
|
({filteredData.length !== data.length
|
|
|
|
|
? `${filteredData.length} / ${data.length}건`
|
|
|
|
|
: `${data.length}건`})
|
2026-01-08 17:05:27 +09:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
2026-01-09 11:51:35 +09:00
|
|
|
{/* 필드 선택기 버튼 */}
|
|
|
|
|
{fieldChooser?.enabled !== false && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={() => setShowFieldChooser(true)}
|
|
|
|
|
title="필드 선택기"
|
|
|
|
|
>
|
|
|
|
|
<LayoutGrid className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 필드 패널 토글 */}
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={() => setShowFieldPanel(!showFieldPanel)}
|
|
|
|
|
title={showFieldPanel ? "필드 패널 숨기기" : "필드 패널 보기"}
|
|
|
|
|
>
|
|
|
|
|
<Settings className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
{allowExpandAll && (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
2026-01-16 14:29:19 +09:00
|
|
|
onClick={handleCollapseAll}
|
|
|
|
|
title="전체 축소"
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
|
|
|
|
<ChevronDown className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
2026-01-16 14:29:19 +09:00
|
|
|
onClick={handleExpandAll}
|
|
|
|
|
title="전체 확장"
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
{/* 차트 토글 */}
|
|
|
|
|
{chartConfig && (
|
2026-01-08 17:05:27 +09:00
|
|
|
<Button
|
2026-01-09 11:51:35 +09:00
|
|
|
variant={showChart ? "secondary" : "ghost"}
|
2026-01-08 17:05:27 +09:00
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
2026-01-09 11:51:35 +09:00
|
|
|
onClick={() => setShowChart(!showChart)}
|
|
|
|
|
title={showChart ? "차트 숨기기" : "차트 보기"}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
2026-01-09 11:51:35 +09:00
|
|
|
<BarChart3 className="h-4 w-4" />
|
2026-01-08 17:05:27 +09:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-09 11:51:35 +09:00
|
|
|
{/* 내보내기 버튼들 */}
|
|
|
|
|
{exportConfig?.excel && (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handleExportCSV}
|
|
|
|
|
title="CSV 내보내기"
|
|
|
|
|
>
|
|
|
|
|
<Download className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handleExportExcel}
|
|
|
|
|
title="Excel 내보내기"
|
|
|
|
|
>
|
|
|
|
|
<FileSpreadsheet className="h-4 w-4" />
|
|
|
|
|
</Button>
|
2026-01-09 15:11:30 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handlePrint}
|
|
|
|
|
title="인쇄"
|
|
|
|
|
>
|
|
|
|
|
<Printer className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handleExportPDF}
|
|
|
|
|
title="PDF 내보내기"
|
|
|
|
|
>
|
|
|
|
|
<FileText className="h-4 w-4" />
|
|
|
|
|
</Button>
|
2026-01-09 11:51:35 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-09 15:11:30 +09:00
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handleRefreshData}
|
|
|
|
|
disabled={isRefreshing}
|
|
|
|
|
title="새로고침"
|
|
|
|
|
>
|
|
|
|
|
{isRefreshing ? (
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<RefreshCw className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handleSaveState}
|
|
|
|
|
title="상태 저장"
|
|
|
|
|
>
|
|
|
|
|
<Save className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={handleResetState}
|
|
|
|
|
title="상태 초기화"
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{/* 숨겨진 필드 표시 드롭다운 */}
|
|
|
|
|
{hiddenFieldsList.length > 0 && (
|
|
|
|
|
<div className="relative group">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
title={`숨겨진 필드: ${hiddenFieldsList.length}개`}
|
|
|
|
|
>
|
|
|
|
|
<EyeOff className="h-4 w-4" />
|
|
|
|
|
<span className="ml-1 text-xs">{hiddenFieldsList.length}</span>
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50 bg-popover border border-border rounded-md shadow-lg min-w-[160px]">
|
|
|
|
|
<div className="p-2 border-b border-border">
|
|
|
|
|
<span className="text-xs font-medium text-muted-foreground">숨겨진 필드</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="max-h-[200px] overflow-y-auto">
|
|
|
|
|
{hiddenFieldsList.map((field) => (
|
|
|
|
|
<button
|
|
|
|
|
key={field.field}
|
|
|
|
|
className="w-full px-3 py-1.5 text-xs text-left hover:bg-accent flex items-center gap-2"
|
|
|
|
|
onClick={() => toggleFieldVisibility(field.field)}
|
|
|
|
|
>
|
|
|
|
|
<Eye className="h-3 w-3" />
|
|
|
|
|
<span>{field.caption || field.field}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="p-1 border-t border-border">
|
|
|
|
|
<button
|
|
|
|
|
className="w-full px-3 py-1.5 text-xs text-left hover:bg-accent text-primary"
|
|
|
|
|
onClick={showAllFields}
|
|
|
|
|
>
|
|
|
|
|
모두 표시
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-09 11:51:35 +09:00
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2"
|
|
|
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
|
|
|
title={isFullscreen ? "원래 크기" : "전체 화면"}
|
|
|
|
|
>
|
|
|
|
|
{isFullscreen ? (
|
|
|
|
|
<Minimize2 className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<Maximize2 className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
{/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */}
|
|
|
|
|
{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",
|
2026-01-16 15:17:49 +09:00
|
|
|
"border transition-colors max-w-xs",
|
2026-01-09 14:41:27 +09:00
|
|
|
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"
|
|
|
|
|
)}
|
2026-01-16 15:17:49 +09:00
|
|
|
title={isFiltered ? `${filterField.caption}: ${selectedValues.join(", ")}` : filterField.caption}
|
2026-01-09 14:41:27 +09:00
|
|
|
>
|
2026-01-16 15:17:49 +09:00
|
|
|
<span className="font-medium">{filterField.caption}:</span>
|
|
|
|
|
{isFiltered ? (
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{selectedValues.length <= 2
|
|
|
|
|
? selectedValues.join(", ")
|
|
|
|
|
: `${selectedValues.slice(0, 2).join(", ")} 외 ${selectedValues.length - 2}개`
|
|
|
|
|
}
|
2026-01-09 14:41:27 +09:00
|
|
|
</span>
|
2026-01-16 15:17:49 +09:00
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">전체</span>
|
2026-01-09 14:41:27 +09:00
|
|
|
)}
|
2026-01-16 15:17:49 +09:00
|
|
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
2026-01-09 14:41:27 +09:00
|
|
|
</button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-08 17:05:27 +09:00
|
|
|
{/* 피벗 테이블 */}
|
2026-01-09 14:41:27 +09:00
|
|
|
<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">
|
2026-01-08 17:05:27 +09:00
|
|
|
<thead>
|
|
|
|
|
{/* 열 헤더 */}
|
2026-01-16 14:03:07 +09:00
|
|
|
<tr className="bg-background">
|
2026-01-09 14:41:27 +09:00
|
|
|
{/* 좌상단 코너 (행 필드 라벨 + 필터) */}
|
2026-01-08 17:05:27 +09:00
|
|
|
<th
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-r border-b border-border",
|
2026-01-16 14:03:07 +09:00
|
|
|
"px-2 py-1 text-left text-xs font-medium",
|
|
|
|
|
"bg-background sticky left-0 top-0 z-20"
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
|
|
|
|
rowSpan={columnFields.length > 0 ? 2 : 1}
|
|
|
|
|
>
|
2026-01-09 14:41:27 +09:00
|
|
|
<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>
|
2026-01-08 17:05:27 +09:00
|
|
|
</th>
|
|
|
|
|
|
|
|
|
|
{/* 열 헤더 셀 */}
|
|
|
|
|
{flatColumns.map((col, idx) => (
|
|
|
|
|
<th
|
|
|
|
|
key={idx}
|
|
|
|
|
className={cn(
|
2026-01-09 15:11:30 +09:00
|
|
|
"border-r border-b border-border relative group",
|
2026-01-16 14:03:07 +09:00
|
|
|
"px-2 py-1 text-center text-xs font-medium",
|
|
|
|
|
"bg-background sticky top-0 z-10",
|
2026-01-09 14:41:27 +09:00
|
|
|
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
|
|
|
|
colSpan={dataFields.length || 1}
|
2026-01-09 15:11:30 +09:00
|
|
|
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
|
2026-01-09 14:41:27 +09:00
|
|
|
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
2026-01-09 14:41:27 +09:00
|
|
|
<div className="flex items-center justify-center gap-1">
|
|
|
|
|
<span>{col.caption || "(전체)"}</span>
|
|
|
|
|
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
|
|
|
|
</div>
|
2026-01-09 15:11:30 +09:00
|
|
|
{/* 열 리사이즈 핸들 */}
|
|
|
|
|
<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)}
|
|
|
|
|
/>
|
2026-01-08 17:05:27 +09:00
|
|
|
</th>
|
|
|
|
|
))}
|
2026-01-16 14:03:07 +09:00
|
|
|
|
|
|
|
|
{/* 행 총계 헤더 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2026-01-09 14:41:27 +09:00
|
|
|
|
2026-01-16 14:03:07 +09:00
|
|
|
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
|
2026-01-09 14:41:27 +09:00
|
|
|
{columnFields.length > 0 && (
|
|
|
|
|
<th
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-b border-border",
|
2026-01-16 14:03:07 +09:00
|
|
|
"px-1 py-1 text-center text-xs",
|
|
|
|
|
"bg-background sticky top-0 z-10"
|
2026-01-09 14:41:27 +09:00
|
|
|
)}
|
2026-01-16 14:03:07 +09:00
|
|
|
rowSpan={dataFields.length > 1 ? 2 : 1}
|
2026-01-09 14:41:27 +09:00
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
2026-01-08 17:05:27 +09:00
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
|
|
|
|
|
{dataFields.length > 1 && (
|
2026-01-16 14:03:07 +09:00
|
|
|
<tr className="bg-background">
|
2026-01-08 17:05:27 +09:00
|
|
|
{flatColumns.map((col, colIdx) => (
|
|
|
|
|
<React.Fragment key={colIdx}>
|
|
|
|
|
{dataFields.map((df, dfIdx) => (
|
|
|
|
|
<th
|
|
|
|
|
key={`${colIdx}-${dfIdx}`}
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-r border-b border-border",
|
2026-01-16 14:03:07 +09:00
|
|
|
"px-2 py-0.5 text-center text-xs font-normal",
|
2026-01-09 14:41:27 +09:00
|
|
|
"text-muted-foreground cursor-pointer hover:bg-accent/50"
|
2026-01-08 17:05:27 +09:00
|
|
|
)}
|
2026-01-09 14:41:27 +09:00
|
|
|
onClick={() => handleSort(df.field)}
|
2026-01-08 17:05:27 +09:00
|
|
|
>
|
2026-01-09 14:41:27 +09:00
|
|
|
<div className="flex items-center justify-center gap-1">
|
|
|
|
|
<span>{df.caption}</span>
|
|
|
|
|
<SortIcon field={df.field} />
|
|
|
|
|
</div>
|
2026-01-08 17:05:27 +09:00
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</thead>
|
|
|
|
|
|
|
|
|
|
<tbody>
|
2026-01-09 15:11:30 +09:00
|
|
|
{/* 열 총계 행 (상단 위치) */}
|
|
|
|
|
{totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && (
|
|
|
|
|
<tr className="bg-primary/5 font-medium">
|
|
|
|
|
<td
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-r border-b border-border",
|
|
|
|
|
"px-2 py-1.5 text-left text-sm",
|
|
|
|
|
"bg-primary/10 sticky left-0"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
총계
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{flatColumns.map((col, colIdx) => (
|
|
|
|
|
<DataCell
|
|
|
|
|
key={colIdx}
|
|
|
|
|
values={grandTotals.column.get(pathToKey(col.path)) || []}
|
|
|
|
|
isTotal
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* 대총합 */}
|
|
|
|
|
{totals?.showRowGrandTotals && (
|
|
|
|
|
<DataCell values={grandTotals.grand} isTotal />
|
|
|
|
|
)}
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
{/* 가상 스크롤 상단 여백 */}
|
|
|
|
|
{enableVirtualScroll && virtualScroll.offsetTop > 0 && (
|
|
|
|
|
<tr style={{ height: virtualScroll.offsetTop }}>
|
|
|
|
|
<td colSpan={rowFields.length + flatColumns.length + (totals?.showRowGrandTotals ? dataFields.length : 0)} />
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
{(() => {
|
|
|
|
|
// 셀 병합 정보 계산
|
|
|
|
|
const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false);
|
2026-01-09 14:41:27 +09:00
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
return visibleFlatRows.map((row, idx) => {
|
|
|
|
|
// 실제 행 인덱스 계산
|
|
|
|
|
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
|
|
|
|
|
const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false };
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
key={rowIdx}
|
|
|
|
|
className={cn(
|
|
|
|
|
style?.alternateRowColors &&
|
|
|
|
|
rowIdx % 2 === 1 &&
|
|
|
|
|
"bg-muted/20"
|
|
|
|
|
)}
|
|
|
|
|
style={{ height: ROW_HEIGHT }}
|
|
|
|
|
>
|
|
|
|
|
{/* 행 헤더 (병합되면 skip) */}
|
|
|
|
|
{!cellMerge.skip && (
|
|
|
|
|
<RowHeaderCell
|
|
|
|
|
row={row}
|
|
|
|
|
rowFields={rowFields}
|
|
|
|
|
onToggleExpand={handleToggleRowExpand}
|
|
|
|
|
rowSpan={cellMerge.rowSpan}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-01-09 11:51:35 +09:00
|
|
|
|
2026-01-09 14:41:27 +09:00
|
|
|
{/* 데이터 셀 */}
|
|
|
|
|
{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;
|
|
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
// 선택 상태 확인 (범위 선택 포함)
|
|
|
|
|
const isCellSelected = isCellInRange(rowIdx, colIdx);
|
2026-01-09 14:41:27 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DataCell
|
|
|
|
|
key={colIdx}
|
|
|
|
|
values={values}
|
|
|
|
|
conditionalStyle={conditionalStyle}
|
|
|
|
|
isSelected={isCellSelected}
|
2026-01-09 15:11:30 +09:00
|
|
|
onClick={(e?: React.MouseEvent) => {
|
|
|
|
|
handleCellSelect(rowIdx, colIdx, e?.shiftKey || false);
|
2026-01-09 14:41:27 +09:00
|
|
|
if (onCellClick) {
|
|
|
|
|
handleCellClick(row.path, col.path, values);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onDoubleClick={() =>
|
|
|
|
|
handleCellDoubleClick(row.path, col.path, values)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* 행 총계 */}
|
|
|
|
|
{totals?.showRowGrandTotals && (
|
2026-01-08 17:05:27 +09:00
|
|
|
<DataCell
|
2026-01-09 14:41:27 +09:00
|
|
|
values={grandTotals.row.get(pathToKey(row.path)) || []}
|
|
|
|
|
isTotal
|
2026-01-08 17:05:27 +09:00
|
|
|
/>
|
2026-01-09 14:41:27 +09:00
|
|
|
)}
|
|
|
|
|
</tr>
|
2026-01-09 15:11:30 +09:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
})()}
|
2026-01-09 14:41:27 +09:00
|
|
|
|
|
|
|
|
{/* 가상 스크롤 하단 여백 */}
|
|
|
|
|
{enableVirtualScroll && (
|
|
|
|
|
<tr style={{ height: virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT) }}>
|
|
|
|
|
<td colSpan={rowFields.length + flatColumns.length + (totals?.showRowGrandTotals ? dataFields.length : 0)} />
|
2026-01-08 17:05:27 +09:00
|
|
|
</tr>
|
2026-01-09 14:41:27 +09:00
|
|
|
)}
|
2026-01-08 17:05:27 +09:00
|
|
|
|
2026-01-09 15:11:30 +09:00
|
|
|
{/* 열 총계 행 (하단 위치 - 기본값) */}
|
|
|
|
|
{totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && (
|
2026-01-08 17:05:27 +09:00
|
|
|
<tr className="bg-primary/5 font-medium">
|
|
|
|
|
<td
|
|
|
|
|
className={cn(
|
|
|
|
|
"border-r border-b border-border",
|
|
|
|
|
"px-2 py-1.5 text-left text-sm",
|
|
|
|
|
"bg-primary/10 sticky left-0"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
총계
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{flatColumns.map((col, colIdx) => (
|
|
|
|
|
<DataCell
|
|
|
|
|
key={colIdx}
|
|
|
|
|
values={grandTotals.column.get(pathToKey(col.path)) || []}
|
|
|
|
|
isTotal
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* 대총합 */}
|
|
|
|
|
{totals?.showRowGrandTotals && (
|
|
|
|
|
<DataCell values={grandTotals.grand} isTotal />
|
|
|
|
|
)}
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
2026-01-09 11:51:35 +09:00
|
|
|
|
|
|
|
|
{/* 차트 */}
|
|
|
|
|
{showChart && chartConfig && pivotResult && (
|
|
|
|
|
<PivotChart
|
|
|
|
|
pivotResult={pivotResult}
|
|
|
|
|
config={{
|
|
|
|
|
...chartConfig,
|
|
|
|
|
enabled: true,
|
|
|
|
|
}}
|
|
|
|
|
dataFields={dataFields}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 필드 선택기 모달 */}
|
|
|
|
|
<FieldChooser
|
|
|
|
|
open={showFieldChooser}
|
|
|
|
|
onOpenChange={setShowFieldChooser}
|
|
|
|
|
availableFields={availableFields}
|
|
|
|
|
selectedFields={fields}
|
|
|
|
|
onFieldsChange={handleFieldsChange}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Drill Down 모달 */}
|
|
|
|
|
<DrillDownModal
|
|
|
|
|
open={drillDownData.open}
|
|
|
|
|
onOpenChange={(open) => setDrillDownData((prev) => ({ ...prev, open }))}
|
|
|
|
|
cellData={drillDownData.cellData}
|
|
|
|
|
data={data}
|
|
|
|
|
fields={fields}
|
|
|
|
|
rowFields={rowFields}
|
|
|
|
|
columnFields={columnFields}
|
|
|
|
|
/>
|
2026-01-08 17:05:27 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default PivotGridComponent;
|