From ba20a2bf425fec5274af7555646703ab665b6584 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 9 Jan 2026 15:11:30 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=BC=EB=B2=97=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A7=8C=20=ED=95=98=EB=A9=B4=20=EB=90=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=80=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 629 ++++++++++++++++-- .../pivot-grid/PivotGridConfigPanel.tsx | 196 ++++++ .../pivot-grid/components/FieldChooser.tsx | 9 + .../registry/components/pivot-grid/types.ts | 7 + 4 files changed, 802 insertions(+), 39 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index b0e8d207..4f4595ff 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -40,9 +40,62 @@ import { ArrowUp, ArrowDown, ArrowUpDown, + Printer, + Save, + RotateCcw, + FileText, + Loader2, + Eye, + EyeOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; +// ==================== 유틸리티 함수 ==================== + +// 셀 병합 정보 계산 +interface MergeCellInfo { + rowSpan: number; + skip: boolean; // 병합된 셀에서 건너뛸지 여부 +} + +const calculateMergeCells = ( + rows: PivotFlatRow[], + mergeCells: boolean +): Map => { + const mergeInfo = new Map(); + + 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; +}; + // ==================== 서브 컴포넌트 ==================== // 행 헤더 셀 @@ -50,12 +103,14 @@ interface RowHeaderCellProps { row: PivotFlatRow; rowFields: PivotFieldConfig[]; onToggleExpand: (path: string[]) => void; + rowSpan?: number; } const RowHeaderCell: React.FC = ({ row, rowFields, onToggleExpand, + rowSpan = 1, }) => { const indentSize = row.level * 20; @@ -68,6 +123,7 @@ const RowHeaderCell: React.FC = ({ row.isExpanded && "bg-muted/70" )} style={{ paddingLeft: `${8 + indentSize}px` }} + rowSpan={rowSpan > 1 ? rowSpan : undefined} >
{row.hasChildren && ( @@ -94,7 +150,7 @@ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; isSelected?: boolean; - onClick?: () => void; + onClick?: (e?: React.MouseEvent) => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; } @@ -133,12 +189,17 @@ const DataCell: React.FC = ({ ); } + // 툴팁 내용 생성 + const tooltipContent = values.map((v) => + `${v.field || "값"}: ${v.formattedValue || v.value}` + ).join("\n"); + // 단일 데이터 필드인 경우 if (values.length === 1) { return ( = ({ style={cellStyle} onClick={onClick} onDoubleClick={onDoubleClick} + title={tooltipContent} > {/* Data Bar */} {hasDataBar && ( @@ -182,6 +244,7 @@ const DataCell: React.FC = ({ style={cellStyle} onClick={onClick} onDoubleClick={onDoubleClick} + title={`${val.field || "값"}: ${val.formattedValue || val.value}`} > {hasDataBar && (
= ({ const [containerHeight, setContainerHeight] = useState(400); const tableContainerRef = useRef(null); - // 셀 선택 상태 + // 셀 선택 상태 (범위 선택 지원) const [selectedCell, setSelectedCell] = useState<{ rowIndex: number; colIndex: number; } | null>(null); + const [selectionRange, setSelectionRange] = useState<{ + startRow: number; + startCol: number; + endRow: number; + endCol: number; + } | null>(null); const tableRef = useRef(null); // 정렬 상태 @@ -272,6 +341,12 @@ export const PivotGridComponent: React.FC = ({ field: string; direction: "asc" | "desc"; } | null>(null); + + // 열 너비 상태 + const [columnWidths, setColumnWidths] = useState>({}); + const [resizingColumn, setResizingColumn] = useState(null); + const [resizeStartX, setResizeStartX] = useState(0); + const [resizeStartWidth, setResizeStartWidth] = useState(0); // 외부 fields 변경 시 동기화 useEffect(() => { @@ -280,6 +355,38 @@ export const PivotGridComponent: React.FC = ({ } }, [initialFields]); + // 상태 저장 키 + 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]); + // 데이터 const data = externalData || []; @@ -456,12 +563,72 @@ export const PivotGridComponent: React.FC = ({ return () => observer.disconnect(); }, []); + // 열 크기 조절 중 + 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]); + // 가상 스크롤 훅 사용 const flatRows = pivotResult?.flatRows || []; - const enableVirtualScroll = flatRows.length > VIRTUAL_SCROLL_THRESHOLD; + + // 정렬된 행 데이터 + 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; const virtualScroll = useVirtualScroll({ - itemCount: flatRows.length, + itemCount: sortedFlatRows.length, itemHeight: ROW_HEIGHT, containerHeight: containerHeight, overscan: 10, @@ -469,9 +636,9 @@ export const PivotGridComponent: React.FC = ({ // 가상 스크롤 적용된 행 데이터 const visibleFlatRows = useMemo(() => { - if (!enableVirtualScroll) return flatRows; - return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); - }, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + if (!enableVirtualScroll) return sortedFlatRows; + return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); // 조건부 서식 스타일 계산 헬퍼 const getCellConditionalStyle = useCallback( @@ -660,6 +827,154 @@ export const PivotGridComponent: React.FC = ({ console.error("Excel 내보내기 실패:", error); } }, [pivotResult, fields, totals, title]); + + // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) + const handlePrint = useCallback(() => { + const printContent = tableRef.current; + if (!printContent) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) return; + + printWindow.document.write(` + + + + ${title || "피벗 테이블"} + + + +

${title || "피벗 테이블"}

+ ${printContent.outerHTML} + + + `); + + 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]); + + // 상태 초기화 + const handleResetState = useCallback(() => { + localStorage.removeItem(stateStorageKey); + setFields(initialFields); + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setColumnWidths({}); + setSelectedCell(null); + setSelectionRange(null); + }, [stateStorageKey, initialFields]); + + // 필드 숨기기/표시 상태 + const [hiddenFields, setHiddenFields] = useState>(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()); + }, []); // ==================== 렌더링 ==================== @@ -818,7 +1133,27 @@ export const PivotGridComponent: React.FC = ({ case "Escape": e.preventDefault(); setSelectedCell(null); + setSelectionRange(null); break; + 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; default: return; } @@ -828,9 +1163,85 @@ export const PivotGridComponent: React.FC = ({ } }; - // 셀 클릭으로 선택 - const handleCellSelect = (rowIndex: number, colIndex: number) => { - setSelectedCell({ rowIndex, colIndex }); + // 셀 클릭으로 선택 (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); + }); }; // 정렬 토글 @@ -974,8 +1385,101 @@ export const PivotGridComponent: React.FC = ({ > + + )} + + + + + + + + {/* 숨겨진 필드 표시 드롭다운 */} + {hiddenFieldsList.length > 0 && ( +
+ +
+
+ 숨겨진 필드 +
+
+ {hiddenFieldsList.map((field) => ( + + ))} +
+
+ +
+
+
+ )}
@@ -593,6 +669,126 @@ export const PivotGridConfigPanel: React.FC = ({ + + {/* 조건부 서식 */} +
+ +
+ {(config.style?.conditionalFormats || []).map((rule, index) => ( +
+ + + {rule.type === "colorScale" && ( +
+ { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="최소값 색상" + /> + + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="최대값 색상" + /> +
+ )} + + {rule.type === "dataBar" && ( + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, dataBar: { color: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="바 색상" + /> + )} + + {rule.type === "iconSet" && ( + + )} + + +
+ ))} + + +
+
)} diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index ec194a12..de4a8948 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ { value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" }, ]; +const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [ + { value: "none", label: "그룹 없음" }, + { value: "year", label: "년" }, + { value: "quarter", label: "분기" }, + { value: "month", label: "월" }, + { value: "week", label: "주" }, + { value: "day", label: "일" }, +]; + const DATA_TYPE_ICONS: Record = { string: , number: , diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index e711a255..87ba2414 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -90,6 +90,10 @@ export interface PivotFieldConfig { // 계층 관련 displayFolder?: string; // 필드 선택기에서 폴더 구조 isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능) + + // 계산 필드 + isCalculated?: boolean; // 계산 필드 여부 + calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]") } // ==================== 데이터 소스 설정 ==================== @@ -140,11 +144,13 @@ export interface PivotTotalsConfig { showRowGrandTotals?: boolean; // 행 총합계 표시 showRowTotals?: boolean; // 행 소계 표시 rowTotalsPosition?: "first" | "last"; // 소계 위치 + rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단) // 열 총합계 showColumnGrandTotals?: boolean; // 열 총합계 표시 showColumnTotals?: boolean; // 열 소계 표시 columnTotalsPosition?: "first" | "last"; // 소계 위치 + columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측) } // 필드 선택기 설정 @@ -214,6 +220,7 @@ export interface PivotStyleConfig { alternateRowColors?: boolean; highlightTotals?: boolean; // 총합계 강조 conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 + mergeCells?: boolean; // 같은 값 셀 병합 } // ==================== 내보내기 설정 ====================