From 49b4cdf56272762a5734bda02af6e319c71cdb6e Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 29 Apr 2026 14:23:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20pivot=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=B3=B8=EC=B2=B4=20=ED=86=B5=EC=A7=B8=20?= =?UTF-8?q?=ED=9D=A1=EC=88=98=20(T3b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 viewMode 통합 세번째 단계 — Codex 권고 (Pivot 통째 흡수, 회귀 위험 차단) 그대로 적용. v2-pivot-grid 의 본체 + utils + components + hooks + 보조 타입을 모두 table/ 하위로 이전. 리팩토링 X (V2Date picker 패턴 동일). table/types.ts 보조 타입 흡수 - PivotResult, PivotHeaderNode, PivotFlatRow, PivotFlatColumn, PivotCellValue, PivotCellData, PivotGridState, PivotGridProps - PivotDataSourceType, PivotFilterCondition, PivotJoinConfig, PivotDataSourceConfig, PivotTotalsConfig, PivotFieldChooserConfig, PivotChartConfig, PivotConditionalFormatRule, PivotStyleConfig, PivotExportConfig - PivotFieldConfig 에 filterValues / filterType / isCalculated / calculateFormula 누락 속성 추가 - TableConfig 에 pivot 보조 키 (pivotTotals / pivotStyle / pivotFieldChooser / pivotChart / pivotExportConfig) table/utils/pivot/ 4개 파일 이전 (1505줄) - pivotEngine.ts (812) — processPivotData / pathToKey / 헤더 트리 / 매트릭스 / 10종 displayMode (runningTotal, percentDifferenceFromPrevious 등) - aggregation.ts (180) — sum/count/avg/min/max/countDistinct + 포맷 - conditionalFormat.ts (311) — colorScale/dataBar/iconSet/cellValue 4 종 - exportExcel.ts (202) — Excel 내보내기 (xlsx) - 옛 prefix(AggregationType 등) → Pivot prefix 일괄 정리 table/internals/pivot/components/ 7개 파일 이전 (2347줄) - ContextMenu / DrillDownModal / FieldChooser / FieldPanel / FilterPopup / PivotChart / index table/internals/pivot/hooks/ 3개 파일 이전 (570줄) - usePivotState / useVirtualScroll / index table/views/PivotView.tsx 신규 (PivotGridComponent.tsx 1963줄 통째 흡수) - import 경로 일괄 정정 (../../types → ../types, ./utils → ../utils/pivot, ./components → ../internals/pivot/components, ./hooks → ../internals/pivot/hooks) - 컴포넌트 이름 PivotGridComponent → PivotView - 본체 로직 그대로 (리팩토링 X) TableComponent.switch - pivot 분기 placeholder 제거 → PivotView 호출 - DOM filter 에 pivotTotals/pivotStyle/pivotFieldChooser/pivotChart/ pivotExportConfig 추가 13 files, +5400+ insertions. v2-pivot-grid/ 폴더 자체는 Phase T5 dead code 일괄 삭제에서 정리 예정. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/table/TableComponent.tsx | 11 +- .../pivot/components/ContextMenu.tsx | 213 ++ .../pivot/components/DrillDownModal.tsx | 429 ++++ .../pivot/components/FieldChooser.tsx | 450 ++++ .../internals/pivot/components/FieldPanel.tsx | 577 +++++ .../pivot/components/FilterPopup.tsx | 265 +++ .../internals/pivot/components/PivotChart.tsx | 386 ++++ .../table/internals/pivot/components/index.ts | 11 + .../table/internals/pivot/hooks/index.ts | 27 + .../internals/pivot/hooks/usePivotState.ts | 231 ++ .../internals/pivot/hooks/useVirtualScroll.ts | 312 +++ .../lib/registry/components/table/types.ts | 218 ++ .../table/utils/pivot/aggregation.ts | 180 ++ .../table/utils/pivot/conditionalFormat.ts | 311 +++ .../table/utils/pivot/exportExcel.ts | 202 ++ .../components/table/utils/pivot/index.ts | 6 + .../table/utils/pivot/pivotEngine.ts | 812 +++++++ .../components/table/views/PivotView.tsx | 1983 ++++++++++++++++- 18 files changed, 6585 insertions(+), 39 deletions(-) create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/ContextMenu.tsx create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/DrillDownModal.tsx create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/FieldChooser.tsx create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/FieldPanel.tsx create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/FilterPopup.tsx create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/PivotChart.tsx create mode 100644 frontend/lib/registry/components/table/internals/pivot/components/index.ts create mode 100644 frontend/lib/registry/components/table/internals/pivot/hooks/index.ts create mode 100644 frontend/lib/registry/components/table/internals/pivot/hooks/usePivotState.ts create mode 100644 frontend/lib/registry/components/table/internals/pivot/hooks/useVirtualScroll.ts create mode 100644 frontend/lib/registry/components/table/utils/pivot/aggregation.ts create mode 100644 frontend/lib/registry/components/table/utils/pivot/conditionalFormat.ts create mode 100644 frontend/lib/registry/components/table/utils/pivot/exportExcel.ts create mode 100644 frontend/lib/registry/components/table/utils/pivot/index.ts create mode 100644 frontend/lib/registry/components/table/utils/pivot/pivotEngine.ts diff --git a/frontend/lib/registry/components/table/TableComponent.tsx b/frontend/lib/registry/components/table/TableComponent.tsx index 613deb60..60a721a0 100644 --- a/frontend/lib/registry/components/table/TableComponent.tsx +++ b/frontend/lib/registry/components/table/TableComponent.tsx @@ -243,7 +243,8 @@ export const TableComponent: React.FC = ({ selectionMode: _54, showCheckbox: _55, showHeader: _56, showFooter: _57, pagination: _58, rowHeight: _59, striped: _60, hoverable: _61, bordered: _62, splitRatio: _63, groupBy: _64, pivotRows: _65, pivotColumns: _66, pivotValues: _67, - pivotFields: _67a, + pivotFields: _67a, pivotTotals: _67a1, pivotStyle: _67a2, + pivotFieldChooser: _67a3, pivotChart: _67a4, pivotExportConfig: _67a5, cardsPerRow: _67b, cardSpacing: _67c, cardStyle: _67d, cardColumnMapping: _67e, emptyMessage: _68, showToolbar: _69, showExcel: _70, showRefresh: _71, disabled: _72, required: _73, @@ -505,9 +506,13 @@ export const TableComponent: React.FC = ({ case "pivot": return ( ); case "table": diff --git a/frontend/lib/registry/components/table/internals/pivot/components/ContextMenu.tsx b/frontend/lib/registry/components/table/internals/pivot/components/ContextMenu.tsx new file mode 100644 index 00000000..4c557359 --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/ContextMenu.tsx @@ -0,0 +1,213 @@ +"use client"; + +/** + * PivotGrid 컨텍스트 메뉴 컴포넌트 + * 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공 + */ + +import React from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + ArrowUpAZ, + ArrowDownAZ, + Filter, + ChevronDown, + ChevronRight, + Copy, + Eye, + EyeOff, + BarChart3, +} from "lucide-react"; +import { PivotFieldConfig, PivotAggregationType } from "../../../types"; + +interface PivotContextMenuProps { + children: React.ReactNode; + // 현재 컨텍스트 정보 + cellType: "header" | "data" | "rowHeader" | "columnHeader"; + field?: PivotFieldConfig; + rowPath?: string[]; + columnPath?: string[]; + value?: any; + // 콜백 + onSort?: (field: string, direction: "asc" | "desc") => void; + onFilter?: (field: string) => void; + onExpand?: (path: string[]) => void; + onCollapse?: (path: string[]) => void; + onExpandAll?: () => void; + onCollapseAll?: () => void; + onCopy?: (value: any) => void; + onHideField?: (field: string) => void; + onChangeSummary?: (field: string, summaryType: PivotAggregationType) => void; + onDrillDown?: (rowPath: string[], columnPath: string[]) => void; +} + +export const PivotContextMenu: React.FC = ({ + children, + cellType, + field, + rowPath, + columnPath, + value, + onSort, + onFilter, + onExpand, + onCollapse, + onExpandAll, + onCollapseAll, + onCopy, + onHideField, + onChangeSummary, + onDrillDown, +}) => { + const handleCopy = () => { + if (value !== undefined && value !== null) { + navigator.clipboard.writeText(String(value)); + onCopy?.(value); + } + }; + + return ( + + {children} + + {/* 정렬 옵션 (헤더에서만) */} + {(cellType === "rowHeader" || cellType === "columnHeader") && field && ( + <> + + + + 정렬 + + + onSort?.(field.field, "asc")}> + + 오름차순 + + onSort?.(field.field, "desc")}> + + 내림차순 + + + + + + )} + + {/* 확장/축소 옵션 */} + {(cellType === "rowHeader" || cellType === "columnHeader") && ( + <> + {rowPath && rowPath.length > 0 && ( + <> + onExpand?.(rowPath)}> + + 확장 + + onCollapse?.(rowPath)}> + + 축소 + + + )} + + + 전체 확장 + + + + 전체 축소 + + + + )} + + {/* 필터 옵션 */} + {field && onFilter && ( + <> + onFilter(field.field)}> + + 필터 + + + + )} + + {/* 집계 함수 변경 (데이터 필드에서만) */} + {cellType === "data" && field && onChangeSummary && ( + <> + + + + 집계 함수 + + + onChangeSummary(field.field, "sum")} + > + 합계 + + onChangeSummary(field.field, "count")} + > + 개수 + + onChangeSummary(field.field, "avg")} + > + 평균 + + onChangeSummary(field.field, "min")} + > + 최소 + + onChangeSummary(field.field, "max")} + > + 최대 + + + + + + )} + + {/* 드릴다운 (데이터 셀에서만) */} + {cellType === "data" && rowPath && columnPath && onDrillDown && ( + <> + onDrillDown(rowPath, columnPath)}> + + 상세 데이터 보기 + + + + )} + + {/* 필드 숨기기 */} + {field && onHideField && ( + onHideField(field.field)}> + + 필드 숨기기 + + )} + + {/* 복사 */} + + + 복사 + + + + ); +}; + +export default PivotContextMenu; + diff --git a/frontend/lib/registry/components/table/internals/pivot/components/DrillDownModal.tsx b/frontend/lib/registry/components/table/internals/pivot/components/DrillDownModal.tsx new file mode 100644 index 00000000..3ea58b78 --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/DrillDownModal.tsx @@ -0,0 +1,429 @@ +"use client"; + +/** + * DrillDownModal 컴포넌트 + * 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotCellData, PivotFieldConfig } from "../../../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Search, + Download, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ArrowUpDown, + ArrowUp, + ArrowDown, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface DrillDownModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cellData: PivotCellData | null; + data: any[]; // 전체 원본 데이터 + fields: PivotFieldConfig[]; + rowFields: PivotFieldConfig[]; + columnFields: PivotFieldConfig[]; +} + +interface SortConfig { + field: string; + direction: "asc" | "desc"; +} + +// ==================== 메인 컴포넌트 ==================== + +export const DrillDownModal: React.FC = ({ + open, + onOpenChange, + cellData, + data, + fields, + rowFields, + columnFields, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [sortConfig, setSortConfig] = useState(null); + + // 드릴다운 데이터 필터링 + const filteredData = useMemo(() => { + if (!cellData || !data) return []; + + // 행/열 경로에 해당하는 데이터 필터링 + let result = data.filter((row) => { + // 행 경로 매칭 + for (let i = 0; i < cellData.rowPath.length; i++) { + const field = rowFields[i]; + if (field && String(row[field.field]) !== cellData.rowPath[i]) { + return false; + } + } + + // 열 경로 매칭 + for (let i = 0; i < cellData.columnPath.length; i++) { + const field = columnFields[i]; + if (field && String(row[field.field]) !== cellData.columnPath[i]) { + return false; + } + } + + return true; + }); + + // 검색 필터 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter((row) => + Object.values(row).some((val) => + String(val).toLowerCase().includes(query) + ) + ); + } + + // 정렬 + if (sortConfig) { + result = [...result].sort((a, b) => { + const aVal = a[sortConfig.field]; + const bVal = b[sortConfig.field]; + + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + let comparison = 0; + if (typeof aVal === "number" && typeof bVal === "number") { + comparison = aVal - bVal; + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + + return sortConfig.direction === "asc" ? comparison : -comparison; + }); + } + + return result; + }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); + + // 페이지네이션 + const totalPages = Math.ceil(filteredData.length / pageSize); + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * pageSize; + return filteredData.slice(start, start + pageSize); + }, [filteredData, currentPage, pageSize]); + + // 표시할 컬럼 결정 + const displayColumns = useMemo(() => { + // 모든 필드의 field명 수집 + const fieldNames = new Set(); + + // fields에서 가져오기 + fields.forEach((f) => fieldNames.add(f.field)); + + // 데이터에서 추가 컬럼 가져오기 + if (data.length > 0) { + Object.keys(data[0]).forEach((key) => fieldNames.add(key)); + } + + return Array.from(fieldNames).map((fieldName) => { + const fieldConfig = fields.find((f) => f.field === fieldName); + return { + field: fieldName, + caption: fieldConfig?.caption || fieldName, + dataType: fieldConfig?.dataType || "string", + }; + }); + }, [fields, data]); + + // 정렬 토글 + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (!prev || prev.field !== field) { + return { field, direction: "asc" }; + } + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; + }); + }; + + // CSV 내보내기 + const handleExportCSV = () => { + if (filteredData.length === 0) return; + + const headers = displayColumns.map((c) => c.caption); + const rows = filteredData.map((row) => + displayColumns.map((c) => { + const val = row[c.field]; + if (val === null || val === undefined) return ""; + if (typeof val === "string" && val.includes(",")) { + return `"${val}"`; + } + return String(val); + }) + ); + + const csv = [headers.join(","), ...rows.map((r) => r.join(","))].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 = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; + link.click(); + }; + + // 페이지 변경 + const goToPage = (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + // 경로 표시 + const pathDisplay = cellData + ? [ + ...(cellData.rowPath.length > 0 + ? [`행: ${cellData.rowPath.join(" > ")}`] + : []), + ...(cellData.columnPath.length > 0 + ? [`열: ${cellData.columnPath.join(" > ")}`] + : []), + ].join(" | ") + : ""; + + return ( + + + + 상세 데이터 + + {pathDisplay || "선택한 셀의 원본 데이터"} + + ({filteredData.length}건) + + + + + {/* 툴바 */} +
+
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 테이블 */} + +
+ + + + {displayColumns.map((col) => ( + handleSort(col.field)} + > +
+ {col.caption} + {sortConfig?.field === col.field ? ( + sortConfig.direction === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ))} +
+
+ + {paginatedData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + paginatedData.map((row, idx) => ( + + {displayColumns.map((col) => ( + + {formatCellValue(row[col.field], col.dataType)} + + ))} + + )) + )} + +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+
+ {(currentPage - 1) * pageSize + 1} -{" "} + {Math.min(currentPage * pageSize, filteredData.length)} /{" "} + {filteredData.length}건 +
+ +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )} +
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function formatCellValue(value: any, dataType: string): string { + if (value === null || value === undefined) return "-"; + + if (dataType === "number") { + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString(); + } + + if (dataType === "date") { + try { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString("ko-KR"); + } + } catch { + // 변환 실패 시 원본 반환 + } + } + + return String(value); +} + +export default DrillDownModal; + diff --git a/frontend/lib/registry/components/table/internals/pivot/components/FieldChooser.tsx b/frontend/lib/registry/components/table/internals/pivot/components/FieldChooser.tsx new file mode 100644 index 00000000..6582ffa1 --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/FieldChooser.tsx @@ -0,0 +1,450 @@ +"use client"; + +/** + * FieldChooser 컴포넌트 + * 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType, PivotAggregationType, PivotSummaryDisplayMode } from "../../../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Search, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + Plus, + Minus, + Type, + Hash, + Calendar, + ToggleLeft, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface AvailableField { + field: string; + caption: string; + dataType: "string" | "number" | "date" | "boolean"; + isSelected: boolean; + currentArea?: PivotAreaType; +} + +interface FieldChooserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + availableFields: AvailableField[]; + selectedFields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +// ==================== 영역 설정 ==================== + +const AREA_OPTIONS: { + value: PivotAreaType | "none"; + label: string; + icon: React.ReactNode; +}[] = [ + { value: "none", label: "사용 안함", icon: }, + { value: "filter", label: "필터", icon: }, + { value: "row", label: "행", icon: }, + { value: "column", label: "열", icon: }, + { value: "data", label: "데이터", icon: }, +]; + +const SUMMARY_OPTIONS: { value: PivotAggregationType; label: string }[] = [ + { value: "sum", label: "합계" }, + { value: "count", label: "개수" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, + { value: "countDistinct", label: "고유 개수" }, +]; + +const DISPLAY_MODE_OPTIONS: { value: PivotSummaryDisplayMode; label: string }[] = [ + { value: "absoluteValue", label: "절대값" }, + { value: "percentOfRowTotal", label: "행 총계 %" }, + { value: "percentOfColumnTotal", label: "열 총계 %" }, + { value: "percentOfGrandTotal", label: "전체 총계 %" }, + { value: "runningTotalByRow", label: "행 누계" }, + { value: "runningTotalByColumn", label: "열 누계" }, + { value: "differenceFromPrevious", label: "이전 대비 차이" }, + { 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: , + date: , + boolean: , +}; + +// ==================== 필드 아이템 ==================== + +interface FieldItemProps { + field: AvailableField; + config?: PivotFieldConfig; + onAreaChange: (area: PivotAreaType | "none") => void; + onSummaryChange?: (summary: PivotAggregationType) => void; + onDisplayModeChange?: (displayMode: PivotSummaryDisplayMode) => void; +} + +const FieldItem: React.FC = ({ + field, + config, + onAreaChange, + onSummaryChange, + onDisplayModeChange, +}) => { + const currentArea = config?.area || "none"; + const isSelected = currentArea !== "none"; + + return ( +
+ {/* 데이터 타입 아이콘 */} +
+ {DATA_TYPE_ICONS[field.dataType] || } +
+ + {/* 필드명 */} +
+
{field.caption}
+
+ {field.field} +
+
+ + {/* 영역 선택 */} + + + {/* 집계 함수 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onSummaryChange && ( + + )} + + {/* 표시 모드 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onDisplayModeChange && ( + + )} +
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const FieldChooser: React.FC = ({ + open, + onOpenChange, + availableFields, + selectedFields, + onFieldsChange, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">( + "all" + ); + + // 필터링된 필드 목록 + const filteredFields = useMemo(() => { + let result = availableFields; + + // 검색어 필터 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (f) => + f.caption.toLowerCase().includes(query) || + f.field.toLowerCase().includes(query) + ); + } + + // 선택 상태 필터 + if (filterType === "selected") { + result = result.filter((f) => + selectedFields.some((sf) => sf.field === f.field && sf.visible !== false) + ); + } else if (filterType === "unselected") { + result = result.filter( + (f) => + !selectedFields.some( + (sf) => sf.field === f.field && sf.visible !== false + ) + ); + } + + return result; + }, [availableFields, selectedFields, searchQuery, filterType]); + + // 필드 영역 변경 + const handleAreaChange = ( + field: AvailableField, + area: PivotAreaType | "none" + ) => { + const existingConfig = selectedFields.find((f) => f.field === field.field); + + if (area === "none") { + // 필드 제거 또는 숨기기 + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, visible: false } : f + ); + onFieldsChange(newFields); + } + } else { + // 필드 추가 또는 영역 변경 + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field + ? { ...f, area, visible: true } + : f + ); + onFieldsChange(newFields); + } else { + // 새 필드 추가 + const newField: PivotFieldConfig = { + field: field.field, + caption: field.caption, + area, + dataType: field.dataType, + visible: true, + summaryType: area === "data" ? "sum" : undefined, + areaIndex: selectedFields.filter((f) => f.area === area).length, + }; + onFieldsChange([...selectedFields, newField]); + } + } + }; + + // 집계 함수 변경 + const handleSummaryChange = ( + field: AvailableField, + summaryType: PivotAggregationType + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryType } : f + ); + onFieldsChange(newFields); + }; + + // 표시 모드 변경 + const handleDisplayModeChange = ( + field: AvailableField, + displayMode: PivotSummaryDisplayMode + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f + ); + onFieldsChange(newFields); + }; + + // 모든 필드 선택 해제 + const handleClearAll = () => { + const newFields = selectedFields.map((f) => ({ ...f, visible: false })); + onFieldsChange(newFields); + }; + + // 통계 + const stats = useMemo(() => { + const visible = selectedFields.filter((f) => f.visible !== false); + return { + total: availableFields.length, + selected: visible.length, + filter: visible.filter((f) => f.area === "filter").length, + row: visible.filter((f) => f.area === "row").length, + column: visible.filter((f) => f.area === "column").length, + data: visible.filter((f) => f.area === "data").length, + }; + }, [availableFields, selectedFields]); + + return ( + + + + 필드 선택기 + + 피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요. + + + + {/* 통계 */} +
+ 전체: {stats.total} + + 선택됨: {stats.selected} + + 필터: {stats.filter} + 행: {stats.row} + 열: {stats.column} + 데이터: {stats.data} +
+ + {/* 검색 및 필터 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 필드 목록 */} + +
+ {filteredFields.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredFields.map((field) => { + const config = selectedFields.find( + (f) => f.field === field.field && f.visible !== false + ); + return ( + handleAreaChange(field, area)} + onSummaryChange={ + config?.area === "data" + ? (summary) => handleSummaryChange(field, summary) + : undefined + } + onDisplayModeChange={ + config?.area === "data" + ? (mode) => handleDisplayModeChange(field, mode) + : undefined + } + /> + ); + }) + )} +
+
+ + {/* 푸터 */} +
+ +
+
+
+ ); +}; + +export default FieldChooser; + diff --git a/frontend/lib/registry/components/table/internals/pivot/components/FieldPanel.tsx b/frontend/lib/registry/components/table/internals/pivot/components/FieldPanel.tsx new file mode 100644 index 00000000..47d740f1 --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/FieldPanel.tsx @@ -0,0 +1,577 @@ +"use client"; + +/** + * FieldPanel 컴포넌트 + * 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터) + * 드래그 앤 드롭으로 필드 재배치 가능 + */ + +import React, { useState } from "react"; +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType } from "../../../types"; +import { + X, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + ChevronDown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +// ==================== 타입 ==================== + +interface FieldPanelProps { + fields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; + onFieldRemove?: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + collapsed?: boolean; + onToggleCollapse?: () => void; +} + +interface FieldChipProps { + field: PivotFieldConfig; + onRemove: () => void; + onSettingsChange?: (field: PivotFieldConfig) => void; +} + +interface DroppableAreaProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + title: string; + icon: React.ReactNode; + onFieldRemove: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + isOver?: boolean; +} + +// ==================== 영역 설정 ==================== + +const AREA_CONFIG: Record< + PivotAreaType, + { title: string; icon: React.ReactNode; color: string } +> = { + filter: { + title: "필터", + icon: , + color: "bg-amber-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", + }, + column: { + title: "열", + icon: , + color: "bg-primary/10 border-primary/20 dark:bg-primary/10 dark:border-primary/30", + }, + row: { + title: "행", + icon: , + color: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800", + }, + data: { + title: "데이터", + icon: , + color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", + }, +}; + +// ==================== 필드 칩 (드래그 가능) ==================== + +const SortableFieldChip: React.FC = ({ + field, + onRemove, + onSettingsChange, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${field.area}-${field.field}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 드래그 핸들 */} + + + {/* 필드 라벨 */} + + + + + + {field.area === "data" && ( + <> + + onSettingsChange?.({ ...field, summaryType: "sum" }) + } + > + 합계 + + + onSettingsChange?.({ ...field, summaryType: "count" }) + } + > + 개수 + + + onSettingsChange?.({ ...field, summaryType: "avg" }) + } + > + 평균 + + + onSettingsChange?.({ ...field, summaryType: "min" }) + } + > + 최소 + + + onSettingsChange?.({ ...field, summaryType: "max" }) + } + > + 최대 + + + + )} + + onSettingsChange?.({ + ...field, + sortOrder: field.sortOrder === "asc" ? "desc" : "asc", + }) + } + > + {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} + + + onSettingsChange?.({ ...field, visible: false })} + > + 필드 숨기기 + + + + + {/* 삭제 버튼 */} + +
+ ); +}; + +// ==================== 드롭 영역 ==================== + +const DroppableArea: React.FC = ({ + area, + fields, + title, + icon, + onFieldRemove, + onFieldSettingsChange, + isOver, +}) => { + const config = AREA_CONFIG[area]; + const areaFields = fields.filter((f) => f.area === area && f.visible !== false); + const fieldIds = areaFields.map((f) => `${area}-${f.field}`); + + return ( +
+ {/* 영역 헤더 */} +
+ {icon} + {title} + {areaFields.length > 0 && ( + + {areaFields.length} + + )} +
+ + {/* 필드 목록 */} + +
+ {areaFields.length === 0 ? ( + + 필드를 여기로 드래그 + + ) : ( + areaFields.map((field) => ( + onFieldRemove(field)} + onSettingsChange={onFieldSettingsChange} + /> + )) + )} +
+
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function getSummaryLabel(type: string): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유", + }; + return labels[type] || type; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FieldPanel: React.FC = ({ + fields, + onFieldsChange, + onFieldRemove, + onFieldSettingsChange, + collapsed = false, + onToggleCollapse, +}) => { + const [activeId, setActiveId] = useState(null); + const [overArea, setOverArea] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 드래그 시작 + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + // 드래그 오버 + const handleDragOver = (event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverArea(null); + return; + } + + // 드롭 영역 감지 + const overId = over.id as string; + const targetArea = overId.split("-")[0] as PivotAreaType; + if (["filter", "column", "row", "data"].includes(targetArea)) { + setOverArea(targetArea); + } + }; + + // 드래그 종료 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverArea(null); + + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + // 필드 정보 파싱 + const [sourceArea, sourceField] = activeId.split("-") as [ + PivotAreaType, + string + ]; + const [targetArea] = overId.split("-") as [PivotAreaType, string]; + + // 같은 영역 내 정렬 + if (sourceArea === targetArea) { + const areaFields = fields.filter((f) => f.area === sourceArea); + const sourceIndex = areaFields.findIndex((f) => f.field === sourceField); + const targetIndex = areaFields.findIndex( + (f) => `${f.area}-${f.field}` === overId + ); + + if (sourceIndex !== targetIndex && targetIndex >= 0) { + // 순서 변경 + const newFields = [...fields]; + const fieldToMove = newFields.find( + (f) => f.field === sourceField && f.area === sourceArea + ); + if (fieldToMove) { + fieldToMove.areaIndex = targetIndex; + // 다른 필드들 인덱스 조정 + newFields + .filter((f) => f.area === sourceArea && f.field !== sourceField) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .forEach((f, idx) => { + f.areaIndex = idx >= targetIndex ? idx + 1 : idx; + }); + } + onFieldsChange(newFields); + } + return; + } + + // 다른 영역으로 이동 + if (["filter", "column", "row", "data"].includes(targetArea)) { + const newFields = fields.map((f) => { + if (f.field === sourceField && f.area === sourceArea) { + return { + ...f, + area: targetArea as PivotAreaType, + areaIndex: fields.filter((ff) => ff.area === targetArea).length, + }; + } + return f; + }); + onFieldsChange(newFields); + } + }; + + // 필드 제거 + const handleFieldRemove = (field: PivotFieldConfig) => { + if (onFieldRemove) { + onFieldRemove(field); + } else { + // 기본 동작: visible을 false로 설정 + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, visible: false } + : f + ); + onFieldsChange(newFields); + } + }; + + // 필드 설정 변경 + const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => { + if (onFieldSettingsChange) { + onFieldSettingsChange(updatedField); + } + const newFields = fields.map((f) => + f.field === updatedField.field && f.area === updatedField.area + ? updatedField + : f + ); + onFieldsChange(newFields); + }; + + // 활성 필드 찾기 (드래그 중인 필드) + const activeField = activeId + ? fields.find((f) => `${f.area}-${f.field}` === activeId) + : null; + + // 각 영역의 필드 수 계산 + const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length; + const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length; + const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length; + const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length; + + if (collapsed) { + return ( +
+
+ {filterCount > 0 && ( + + + 필터 {filterCount} + + )} + + + 열 {columnCount} + + + + 행 {rowCount} + + + + 데이터 {dataCount} + +
+ +
+ ); + } + + return ( + +
+ {/* 4개 영역 배치: 2x2 그리드 */} +
+ {/* 필터 영역 */} + + + {/* 열 영역 */} + + + {/* 행 영역 */} + + + {/* 데이터 영역 */} + +
+ + {/* 접기 버튼 */} + {onToggleCollapse && ( +
+ +
+ )} +
+ + {/* 드래그 오버레이 */} + + {activeField ? ( +
+ + {activeField.caption} +
+ ) : null} +
+
+ ); +}; + +export default FieldPanel; + diff --git a/frontend/lib/registry/components/table/internals/pivot/components/FilterPopup.tsx b/frontend/lib/registry/components/table/internals/pivot/components/FilterPopup.tsx new file mode 100644 index 00000000..7297e4fb --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/FilterPopup.tsx @@ -0,0 +1,265 @@ +"use client"; + +/** + * FilterPopup 컴포넌트 + * 피벗 필드의 값을 필터링하는 팝업 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig } from "../../../types"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Search, + Filter, + Check, + X, + CheckSquare, + Square, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface FilterPopupProps { + field: PivotFieldConfig; + data: any[]; + onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void; + trigger?: React.ReactNode; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FilterPopup: React.FC = ({ + field, + data, + onFilterChange, + trigger, +}) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedValues, setSelectedValues] = useState>( + new Set(field.filterValues || []) + ); + const [filterType, setFilterType] = useState<"include" | "exclude">( + field.filterType || "include" + ); + + // 고유 값 추출 + const uniqueValues = useMemo(() => { + const values = new Set(); + data.forEach((row) => { + const value = row[field.field]; + if (value !== null && value !== undefined) { + values.add(value); + } + }); + return Array.from(values).sort((a, b) => { + if (typeof a === "number" && typeof b === "number") return a - b; + return String(a).localeCompare(String(b), "ko"); + }); + }, [data, field.field]); + + // 필터링된 값 목록 + const filteredValues = useMemo(() => { + if (!searchQuery) return uniqueValues; + const query = searchQuery.toLowerCase(); + return uniqueValues.filter((val) => + String(val).toLowerCase().includes(query) + ); + }, [uniqueValues, searchQuery]); + + // 값 토글 + const handleValueToggle = (value: any) => { + const newSelected = new Set(selectedValues); + if (newSelected.has(value)) { + newSelected.delete(value); + } else { + newSelected.add(value); + } + setSelectedValues(newSelected); + }; + + // 모두 선택 + const handleSelectAll = () => { + setSelectedValues(new Set(filteredValues)); + }; + + // 모두 해제 + const handleClearAll = () => { + setSelectedValues(new Set()); + }; + + // 적용 + const handleApply = () => { + onFilterChange(field, Array.from(selectedValues), filterType); + setOpen(false); + }; + + // 초기화 + const handleReset = () => { + setSelectedValues(new Set()); + setFilterType("include"); + onFilterChange(field, [], "include"); + setOpen(false); + }; + + // 필터 활성 상태 + const isFilterActive = field.filterValues && field.filterValues.length > 0; + + // 선택된 항목 수 + const selectedCount = selectedValues.size; + const totalCount = uniqueValues.length; + + return ( + + + {trigger || ( + + )} + + +
+
+ {field.caption} 필터 +
+ + +
+
+ + {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+ + {/* 전체 선택/해제 */} +
+ + {selectedCount} / {totalCount} 선택됨 + +
+ + +
+
+
+ + {/* 값 목록 */} + +
+ {filteredValues.length === 0 ? ( +
+ 결과가 없습니다 +
+ ) : ( + filteredValues.map((value) => ( + + )) + )} +
+
+ + {/* 버튼 */} +
+ +
+ + +
+
+
+
+ ); +}; + +export default FilterPopup; + diff --git a/frontend/lib/registry/components/table/internals/pivot/components/PivotChart.tsx b/frontend/lib/registry/components/table/internals/pivot/components/PivotChart.tsx new file mode 100644 index 00000000..bfb7c94d --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/PivotChart.tsx @@ -0,0 +1,386 @@ +"use client"; + +/** + * PivotChart 컴포넌트 + * 피벗 데이터를 차트로 시각화 + */ + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../../../types"; +import { pathToKey } from "../../../utils/pivot/pivotEngine"; +import { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// ==================== 타입 ==================== + +interface PivotChartProps { + pivotResult: PivotResult; + config: PivotChartConfig; + dataFields: PivotFieldConfig[]; + className?: string; +} + +// ==================== 색상 ==================== + +const COLORS = [ + "#4472C4", // 파랑 + "#ED7D31", // 주황 + "#A5A5A5", // 회색 + "#FFC000", // 노랑 + "#5B9BD5", // 하늘 + "#70AD47", // 초록 + "#264478", // 진한 파랑 + "#9E480E", // 진한 주황 + "#636363", // 진한 회색 + "#997300", // 진한 노랑 +]; + +// ==================== 데이터 변환 ==================== + +function transformDataForChart( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + // 행 기준 차트 데이터 생성 + return flatRows.map((row) => { + const dataPoint: any = { + name: row.caption, + path: row.path, + }; + + // 각 열에 대한 데이터 추가 + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + + if (values && values.length > 0) { + const columnName = col.caption || "전체"; + dataPoint[columnName] = values[0].value; + } + }); + + // 총계 추가 + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + dataPoint["총계"] = rowTotal[0].value; + } + + return dataPoint; + }); +} + +function transformDataForPie( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, grandTotals } = pivotResult; + + return flatRows.map((row, idx) => { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + return { + name: row.caption, + value: rowTotal?.[0]?.value || 0, + color: COLORS[idx % COLORS.length], + }; + }); +} + +// ==================== 차트 컴포넌트 ==================== + +const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + + return ( +
+

{label}

+ {payload.map((entry: any, idx: number) => ( +

+ {entry.name}: {entry.value?.toLocaleString()} +

+ ))} +
+ ); +}; + +// 막대 차트 +const PivotBarChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; + stacked?: boolean; +}> = ({ data, columns, height, showLegend, stacked }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 선 차트 +const PivotLineChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 영역 차트 +const PivotAreaChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 파이 차트 +const PivotPieChart: React.FC<{ + data: any[]; + height: number; + showLegend: boolean; +}> = ({ data, height, showLegend }) => { + return ( + + + + `${name} (${(percent * 100).toFixed(1)}%)` + } + labelLine + > + {data.map((entry, idx) => ( + + ))} + + } /> + {showLegend && ( + + )} + + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotChart: React.FC = ({ + pivotResult, + config, + dataFields, + className, +}) => { + // 차트 데이터 변환 + const chartData = useMemo(() => { + if (config.type === "pie") { + return transformDataForPie(pivotResult, dataFields); + } + return transformDataForChart(pivotResult, dataFields); + }, [pivotResult, dataFields, config.type]); + + // 열 이름 목록 (파이 차트 제외) + const columns = useMemo(() => { + if (config.type === "pie" || chartData.length === 0) return []; + + const firstItem = chartData[0]; + return Object.keys(firstItem).filter( + (key) => key !== "name" && key !== "path" + ); + }, [chartData, config.type]); + + const height = config.height || 300; + const showLegend = config.showLegend !== false; + + if (!config.enabled) { + return null; + } + + return ( +
+ {/* 차트 렌더링 */} + {config.type === "bar" && ( + + )} + + {config.type === "stackedBar" && ( + + )} + + {config.type === "line" && ( + + )} + + {config.type === "area" && ( + + )} + + {config.type === "pie" && ( + + )} +
+ ); +}; + +export default PivotChart; + diff --git a/frontend/lib/registry/components/table/internals/pivot/components/index.ts b/frontend/lib/registry/components/table/internals/pivot/components/index.ts new file mode 100644 index 00000000..9272e7db --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/components/index.ts @@ -0,0 +1,11 @@ +/** + * PivotGrid 서브 컴포넌트 내보내기 + */ + +export { FieldPanel } from "./FieldPanel"; +export { FieldChooser } from "./FieldChooser"; +export { DrillDownModal } from "./DrillDownModal"; +export { FilterPopup } from "./FilterPopup"; +export { PivotChart } from "./PivotChart"; +export { PivotContextMenu } from "./ContextMenu"; + diff --git a/frontend/lib/registry/components/table/internals/pivot/hooks/index.ts b/frontend/lib/registry/components/table/internals/pivot/hooks/index.ts new file mode 100644 index 00000000..a9a1a4eb --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/hooks/index.ts @@ -0,0 +1,27 @@ +/** + * PivotGrid 커스텀 훅 내보내기 + */ + +export { + useVirtualScroll, + useVirtualColumnScroll, + useVirtual2DScroll, +} from "./useVirtualScroll"; + +export type { + VirtualScrollOptions, + VirtualScrollResult, + VirtualColumnScrollOptions, + VirtualColumnScrollResult, + Virtual2DScrollOptions, + Virtual2DScrollResult, +} from "./useVirtualScroll"; + +export { usePivotState } from "./usePivotState"; + +export type { + PivotStateConfig, + SavedPivotState, + UsePivotStateResult, +} from "./usePivotState"; + diff --git a/frontend/lib/registry/components/table/internals/pivot/hooks/usePivotState.ts b/frontend/lib/registry/components/table/internals/pivot/hooks/usePivotState.ts new file mode 100644 index 00000000..96a29c15 --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/hooks/usePivotState.ts @@ -0,0 +1,231 @@ +"use client"; + +/** + * PivotState 훅 + * 피벗 그리드 상태 저장/복원 관리 + */ + +import { useState, useEffect, useCallback } from "react"; +import { PivotFieldConfig, PivotGridState, PivotSortDirection } from "../../../types"; + +// ==================== 타입 ==================== + +export interface PivotStateConfig { + enabled: boolean; + storageKey?: string; + storageType?: "localStorage" | "sessionStorage"; +} + +export interface SavedPivotState { + version: string; + timestamp: number; + fields: PivotFieldConfig[]; + expandedRowPaths: string[][]; + expandedColumnPaths: string[][]; + filterConfig: Record; + sortConfig: { + field: string; + direction: PivotSortDirection; + } | null; +} + +export interface UsePivotStateResult { + // 상태 + fields: PivotFieldConfig[]; + pivotState: PivotGridState; + + // 상태 변경 + setFields: (fields: PivotFieldConfig[]) => void; + setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void; + + // 저장/복원 + saveState: () => void; + loadState: () => boolean; + clearState: () => void; + hasStoredState: () => boolean; + + // 상태 정보 + lastSaved: Date | null; + isDirty: boolean; +} + +// ==================== 상수 ==================== + +const STATE_VERSION = "1.0.0"; +const DEFAULT_STORAGE_KEY = "pivot-grid-state"; + +// ==================== 훅 ==================== + +export function usePivotState( + initialFields: PivotFieldConfig[], + config: PivotStateConfig +): UsePivotStateResult { + const { + enabled, + storageKey = DEFAULT_STORAGE_KEY, + storageType = "localStorage", + } = config; + + // 상태 + const [fields, setFieldsInternal] = useState(initialFields); + const [pivotState, setPivotStateInternal] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + const [lastSaved, setLastSaved] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [initialStateLoaded, setInitialStateLoaded] = useState(false); + + // 스토리지 가져오기 + const getStorage = useCallback(() => { + if (typeof window === "undefined") return null; + return storageType === "localStorage" ? localStorage : sessionStorage; + }, [storageType]); + + // 저장된 상태 확인 + const hasStoredState = useCallback((): boolean => { + const storage = getStorage(); + if (!storage) return false; + return storage.getItem(storageKey) !== null; + }, [getStorage, storageKey]); + + // 상태 저장 + const saveState = useCallback(() => { + if (!enabled) return; + + const storage = getStorage(); + if (!storage) return; + + const stateToSave: SavedPivotState = { + version: STATE_VERSION, + timestamp: Date.now(), + fields, + expandedRowPaths: pivotState.expandedRowPaths, + expandedColumnPaths: pivotState.expandedColumnPaths, + filterConfig: pivotState.filterConfig, + sortConfig: pivotState.sortConfig, + }; + + try { + storage.setItem(storageKey, JSON.stringify(stateToSave)); + setLastSaved(new Date()); + setIsDirty(false); + console.log("✅ 피벗 상태 저장됨:", storageKey); + } catch (error) { + console.error("❌ 피벗 상태 저장 실패:", error); + } + }, [enabled, getStorage, storageKey, fields, pivotState]); + + // 상태 불러오기 + const loadState = useCallback((): boolean => { + if (!enabled) return false; + + const storage = getStorage(); + if (!storage) return false; + + try { + const saved = storage.getItem(storageKey); + if (!saved) return false; + + const parsedState: SavedPivotState = JSON.parse(saved); + + // 버전 체크 + if (parsedState.version !== STATE_VERSION) { + console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨"); + return false; + } + + // 상태 복원 + setFieldsInternal(parsedState.fields); + setPivotStateInternal({ + expandedRowPaths: parsedState.expandedRowPaths, + expandedColumnPaths: parsedState.expandedColumnPaths, + sortConfig: parsedState.sortConfig, + filterConfig: parsedState.filterConfig, + }); + setLastSaved(new Date(parsedState.timestamp)); + setIsDirty(false); + + console.log("✅ 피벗 상태 복원됨:", storageKey); + return true; + } catch (error) { + console.error("❌ 피벗 상태 복원 실패:", error); + return false; + } + }, [enabled, getStorage, storageKey]); + + // 상태 초기화 + const clearState = useCallback(() => { + const storage = getStorage(); + if (!storage) return; + + try { + storage.removeItem(storageKey); + setLastSaved(null); + console.log("🗑️ 피벗 상태 삭제됨:", storageKey); + } catch (error) { + console.error("❌ 피벗 상태 삭제 실패:", error); + } + }, [getStorage, storageKey]); + + // 필드 변경 (dirty 플래그 설정) + const setFields = useCallback((newFields: PivotFieldConfig[]) => { + setFieldsInternal(newFields); + setIsDirty(true); + }, []); + + // 피벗 상태 변경 (dirty 플래그 설정) + const setPivotState = useCallback( + (newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => { + setPivotStateInternal(newState); + setIsDirty(true); + }, + [] + ); + + // 초기 로드 + useEffect(() => { + if (!initialStateLoaded && enabled && hasStoredState()) { + loadState(); + setInitialStateLoaded(true); + } + }, [enabled, hasStoredState, loadState, initialStateLoaded]); + + // 초기 필드 동기화 (저장된 상태가 없을 때) + useEffect(() => { + if (initialStateLoaded) return; + if (!hasStoredState() && initialFields.length > 0) { + setFieldsInternal(initialFields); + setInitialStateLoaded(true); + } + }, [initialFields, hasStoredState, initialStateLoaded]); + + // 자동 저장 (변경 시) + useEffect(() => { + if (!enabled || !isDirty) return; + + const timeout = setTimeout(() => { + saveState(); + }, 1000); // 1초 디바운스 + + return () => clearTimeout(timeout); + }, [enabled, isDirty, saveState]); + + return { + fields, + pivotState, + setFields, + setPivotState, + saveState, + loadState, + clearState, + hasStoredState, + lastSaved, + isDirty, + }; +} + +export default usePivotState; + diff --git a/frontend/lib/registry/components/table/internals/pivot/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/table/internals/pivot/hooks/useVirtualScroll.ts new file mode 100644 index 00000000..7f79cd3e --- /dev/null +++ b/frontend/lib/registry/components/table/internals/pivot/hooks/useVirtualScroll.ts @@ -0,0 +1,312 @@ +"use client"; + +/** + * Virtual Scroll 훅 + * 대용량 피벗 데이터의 가상 스크롤 처리 + */ + +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; + +// ==================== 타입 ==================== + +export interface VirtualScrollOptions { + itemCount: number; // 전체 아이템 수 + itemHeight: number; // 각 아이템 높이 (px) + containerHeight: number; // 컨테이너 높이 (px) + overscan?: number; // 버퍼 아이템 수 (기본: 5) +} + +export interface VirtualScrollResult { + // 현재 보여야 할 아이템 범위 + startIndex: number; + endIndex: number; + + // 가상 스크롤 관련 값 + totalHeight: number; // 전체 높이 + offsetTop: number; // 상단 오프셋 + + // 보여지는 아이템 목록 + visibleItems: number[]; + + // 이벤트 핸들러 + onScroll: (scrollTop: number) => void; + + // 컨테이너 ref + containerRef: React.RefObject; +} + +// ==================== 훅 ==================== + +export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { + const { + itemCount, + itemHeight, + containerHeight, + overscan = 5, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + // 보이는 아이템 수 + const visibleCount = Math.ceil(containerHeight / itemHeight); + + // 시작/끝 인덱스 계산 + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const end = Math.min( + itemCount - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); + + // 전체 높이 + const totalHeight = itemCount * itemHeight; + + // 상단 오프셋 + const offsetTop = startIndex * itemHeight; + + // 보이는 아이템 인덱스 배열 + const visibleItems = useMemo(() => { + const items: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + items.push(i); + } + return items; + }, [startIndex, endIndex]); + + // 스크롤 핸들러 + const onScroll = useCallback((newScrollTop: number) => { + setScrollTop(newScrollTop); + }, []); + + // 스크롤 이벤트 리스너 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + startIndex, + endIndex, + totalHeight, + offsetTop, + visibleItems, + onScroll, + containerRef, + }; +} + +// ==================== 열 가상 스크롤 ==================== + +export interface VirtualColumnScrollOptions { + columnCount: number; // 전체 열 수 + columnWidth: number; // 각 열 너비 (px) + containerWidth: number; // 컨테이너 너비 (px) + overscan?: number; +} + +export interface VirtualColumnScrollResult { + startIndex: number; + endIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + onScroll: (scrollLeft: number) => void; +} + +export function useVirtualColumnScroll( + options: VirtualColumnScrollOptions +): VirtualColumnScrollResult { + const { + columnCount, + columnWidth, + containerWidth, + overscan = 3, + } = options; + + const [scrollLeft, setScrollLeft] = useState(0); + + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]); + + const totalWidth = columnCount * columnWidth; + const offsetLeft = startIndex * columnWidth; + + const visibleColumns = useMemo(() => { + const cols: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + cols.push(i); + } + return cols; + }, [startIndex, endIndex]); + + const onScroll = useCallback((newScrollLeft: number) => { + setScrollLeft(newScrollLeft); + }, []); + + return { + startIndex, + endIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + }; +} + +// ==================== 2D 가상 스크롤 (행 + 열) ==================== + +export interface Virtual2DScrollOptions { + rowCount: number; + columnCount: number; + rowHeight: number; + columnWidth: number; + containerHeight: number; + containerWidth: number; + rowOverscan?: number; + columnOverscan?: number; +} + +export interface Virtual2DScrollResult { + // 행 범위 + rowStartIndex: number; + rowEndIndex: number; + totalHeight: number; + offsetTop: number; + visibleRows: number[]; + + // 열 범위 + columnStartIndex: number; + columnEndIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + + // 스크롤 핸들러 + onScroll: (scrollTop: number, scrollLeft: number) => void; + + // 컨테이너 ref + containerRef: React.RefObject; +} + +export function useVirtual2DScroll( + options: Virtual2DScrollOptions +): Virtual2DScrollResult { + const { + rowCount, + columnCount, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + rowOverscan = 5, + columnOverscan = 3, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + // 행 계산 + const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan); + const end = Math.min( + rowCount - 1, + Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan + ); + + const rows: number[] = []; + for (let i = start; i <= end; i++) { + rows.push(i); + } + + return { + rowStartIndex: start, + rowEndIndex: end, + visibleRows: rows, + }; + }, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]); + + // 열 계산 + const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan + ); + + const cols: number[] = []; + for (let i = start; i <= end; i++) { + cols.push(i); + } + + return { + columnStartIndex: start, + columnEndIndex: end, + visibleColumns: cols, + }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]); + + const totalHeight = rowCount * rowHeight; + const totalWidth = columnCount * columnWidth; + const offsetTop = rowStartIndex * rowHeight; + const offsetLeft = columnStartIndex * columnWidth; + + const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => { + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + }, []); + + // 스크롤 이벤트 리스너 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + setScrollLeft(container.scrollLeft); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + rowStartIndex, + rowEndIndex, + totalHeight, + offsetTop, + visibleRows, + columnStartIndex, + columnEndIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + containerRef, + }; +} + +export default useVirtualScroll; + diff --git a/frontend/lib/registry/components/table/types.ts b/frontend/lib/registry/components/table/types.ts index fd95c1bb..e271cffd 100644 --- a/frontend/lib/registry/components/table/types.ts +++ b/frontend/lib/registry/components/table/types.ts @@ -126,6 +126,214 @@ export interface PivotFieldConfig { width?: number; expanded?: boolean; format?: PivotFieldFormat; + // 필터 설정 (filter area) + filterValues?: any[]; + filterType?: "include" | "exclude"; + // 계산 필드 (수식 기반) + isCalculated?: boolean; + calculateFormula?: string; +} + +// ─── pivot 결과 데이터 구조 (utils/pivot 가 사용) ───────────────── + +export interface PivotCellValue { + field: string; + value: number | null; + formattedValue: string; +} + +export interface PivotHeaderNode { + value: any; + caption: string; + level: number; + children?: PivotHeaderNode[]; + isExpanded: boolean; + path: string[]; + subtotal?: PivotCellValue[]; + span?: number; +} + +export interface PivotFlatRow { + path: string[]; + level: number; + caption: string; + isExpanded: boolean; + hasChildren: boolean; + isTotal?: boolean; +} + +export interface PivotFlatColumn { + path: string[]; + level: number; + caption: string; + span: number; + isTotal?: boolean; +} + +export interface PivotResult { + rowHeaders: PivotHeaderNode[]; + columnHeaders: PivotHeaderNode[]; + dataMatrix: Map; + flatRows: PivotFlatRow[]; + flatColumns: PivotFlatColumn[]; + grandTotals: { + row: Map; + column: Map; + grand: PivotCellValue[]; + }; +} + +export interface PivotCellData { + value: any; + rowPath: string[]; + columnPath: string[]; + field?: string; + aggregationType?: PivotAggregationType; + isTotal?: boolean; + isGrandTotal?: boolean; +} + +// ─── pivot 표시 / 차트 / 조건부 서식 / 데이터 소스 (본체가 사용) ──── + +export type PivotDataSourceType = "table" | "api" | "static"; + +export interface PivotFilterCondition { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; + value?: any; + valueFromField?: string; +} + +export interface PivotJoinConfig { + joinType: "INNER" | "LEFT" | "RIGHT"; + targetTable: string; + sourceColumn: string; + targetColumn: string; + columns: string[]; +} + +export interface PivotDataSourceConfig { + type: PivotDataSourceType; + tableName?: string; + apiEndpoint?: string; + apiMethod?: "GET" | "POST"; + staticData?: any[]; + filterConditions?: PivotFilterCondition[]; + joinConfigs?: PivotJoinConfig[]; +} + +export interface PivotTotalsConfig { + showRowGrandTotals?: boolean; + showRowTotals?: boolean; + rowTotalsPosition?: "first" | "last"; + rowGrandTotalPosition?: "top" | "bottom"; + showColumnGrandTotals?: boolean; + showColumnTotals?: boolean; + columnTotalsPosition?: "first" | "last"; + columnGrandTotalPosition?: "left" | "right"; +} + +export interface PivotFieldChooserConfig { + enabled: boolean; + allowSearch?: boolean; + layout?: "default" | "simplified"; + height?: number; + applyChangesMode?: "instantly" | "onDemand"; +} + +export interface PivotChartConfig { + enabled: boolean; + type: "bar" | "line" | "area" | "pie" | "stackedBar"; + position: "top" | "bottom" | "left" | "right"; + height?: number; + showLegend?: boolean; + animate?: boolean; +} + +export interface PivotConditionalFormatRule { + id: string; + type: "colorScale" | "dataBar" | "iconSet" | "cellValue"; + field?: string; + colorScale?: { + minColor: string; + midColor?: string; + maxColor: string; + }; + dataBar?: { + color: string; + showValue?: boolean; + minValue?: number; + maxValue?: number; + }; + iconSet?: { + type: "arrows" | "traffic" | "rating" | "flags"; + thresholds: number[]; + reverse?: boolean; + }; + cellValue?: { + operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between"; + value1: number; + value2?: number; + backgroundColor?: string; + textColor?: string; + bold?: boolean; + }; +} + +export interface PivotStyleConfig { + theme: "default" | "compact" | "modern"; + headerStyle: "default" | "dark" | "light"; + cellPadding: "compact" | "normal" | "comfortable"; + borderStyle: "none" | "light" | "heavy"; + alternateRowColors?: boolean; + highlightTotals?: boolean; + conditionalFormats?: PivotConditionalFormatRule[]; + mergeCells?: boolean; +} + +export interface PivotExportConfig { + excel?: boolean; + pdf?: boolean; + fileName?: string; +} + +export interface PivotGridState { + expandedRowPaths: string[][]; + expandedColumnPaths: string[][]; + sortConfig: { + field: string; + direction: PivotSortDirection; + } | null; + filterConfig: Record; +} + +// pivot 본체 props (PivotView 가 받음) +export interface PivotGridProps { + id?: string; + title?: string; + dataSource?: PivotDataSourceConfig; + fields?: PivotFieldConfig[]; + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + fieldChooser?: PivotFieldChooserConfig; + chart?: PivotChartConfig; + allowSortingBySummary?: boolean; + allowFiltering?: boolean; + allowExpandAll?: boolean; + wordWrapEnabled?: boolean; + height?: string | number; + maxHeight?: string; + stateStoring?: { + enabled: boolean; + storageKey?: string; + }; + exportConfig?: PivotExportConfig; + data?: any[]; + onCellClick?: (cellData: PivotCellData) => void; + onCellDoubleClick?: (cellData: PivotCellData) => void; + onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void; + onExpandChange?: (expandedPaths: string[][]) => void; + onDataChange?: (data: any[]) => void; } export interface TableConfig extends ComponentConfig { @@ -177,6 +385,16 @@ export interface TableConfig extends ComponentConfig { * 단순 pivotRows/pivotColumns/pivotValues 보다 우선 사용 (있으면). */ pivotFields?: PivotFieldConfig[]; + /** pivot 모드: 총합계 표시 옵션 */ + pivotTotals?: PivotTotalsConfig; + /** pivot 모드: 시각 스타일 + 조건부 서식 */ + pivotStyle?: PivotStyleConfig; + /** pivot 모드: 필드 선택기 */ + pivotFieldChooser?: PivotFieldChooserConfig; + /** pivot 모드: 차트 연동 */ + pivotChart?: PivotChartConfig; + /** pivot 모드: Excel/PDF 내보내기 */ + pivotExportConfig?: PivotExportConfig; // ─── card 모드 전용 ─── /** card 모드: 한 줄에 표시할 카드 수 */ diff --git a/frontend/lib/registry/components/table/utils/pivot/aggregation.ts b/frontend/lib/registry/components/table/utils/pivot/aggregation.ts new file mode 100644 index 00000000..8164f326 --- /dev/null +++ b/frontend/lib/registry/components/table/utils/pivot/aggregation.ts @@ -0,0 +1,180 @@ +/** + * PivotGrid 집계 함수 유틸리티 + * 다양한 집계 연산을 수행합니다. + */ + +import { getFormatRules } from "@/lib/formatting"; + +import { PivotAggregationType, PivotFieldFormat } from "../../types"; + +// ==================== 집계 함수 ==================== + +/** + * 합계 계산 + */ +export function sum(values: number[]): number { + return values.reduce((acc, val) => acc + (val || 0), 0); +} + +/** + * 개수 계산 + */ +export function count(values: any[]): number { + return values.length; +} + +/** + * 평균 계산 + */ +export function avg(values: number[]): number { + if (values.length === 0) return 0; + return sum(values) / values.length; +} + +/** + * 최소값 계산 + */ +export function min(values: number[]): number { + if (values.length === 0) return 0; + return Math.min(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * 최대값 계산 + */ +export function max(values: number[]): number { + if (values.length === 0) return 0; + return Math.max(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * 고유값 개수 계산 + */ +export function countDistinct(values: any[]): number { + return new Set(values.filter((v) => v !== null && v !== undefined)).size; +} + +/** + * 집계 타입에 따른 집계 수행 + */ +export function aggregate( + values: any[], + type: PivotAggregationType = "sum" +): number { + const numericValues = values + .map((v) => (typeof v === "number" ? v : parseFloat(v))) + .filter((v) => !isNaN(v)); + + switch (type) { + case "sum": + return sum(numericValues); + case "count": + return count(values); + case "avg": + return avg(numericValues); + case "min": + return min(numericValues); + case "max": + return max(numericValues); + case "countDistinct": + return countDistinct(values); + default: + return sum(numericValues); + } +} + +// ==================== 포맷 함수 ==================== + +/** + * 숫자 포맷팅 + */ +export function formatNumber( + value: number | null | undefined, + format?: PivotFieldFormat +): string { + if (value === null || value === undefined) return "-"; + + const { + type = "number", + precision = 0, + thousandSeparator = true, + prefix = "", + suffix = "", + } = format || {}; + + let formatted: string; + + const locale = getFormatRules().number.locale; + + switch (type) { + case "currency": + formatted = value.toLocaleString(locale, { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "percent": + formatted = (value * 100).toLocaleString(locale, { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "number": + default: + if (thousandSeparator) { + formatted = value.toLocaleString(locale, { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + } else { + formatted = value.toFixed(precision); + } + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +/** + * 날짜 포맷팅 + */ +export function formatDate( + value: Date | string | null | undefined, + format: string = getFormatRules().date.display +): string { + if (!value) return "-"; + + const date = typeof value === "string" ? new Date(value) : value; + + if (isNaN(date.getTime())) return "-"; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const quarter = Math.ceil((date.getMonth() + 1) / 3); + + return format + .replace("YYYY", String(year)) + .replace("MM", month) + .replace("DD", day) + .replace("Q", `Q${quarter}`); +} + +/** + * 집계 타입 라벨 반환 + */ +export function getAggregationLabel(type: PivotAggregationType): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유값", + }; + return labels[type] || "합계"; +} + + diff --git a/frontend/lib/registry/components/table/utils/pivot/conditionalFormat.ts b/frontend/lib/registry/components/table/utils/pivot/conditionalFormat.ts new file mode 100644 index 00000000..3de73d66 --- /dev/null +++ b/frontend/lib/registry/components/table/utils/pivot/conditionalFormat.ts @@ -0,0 +1,311 @@ +/** + * 조건부 서식 유틸리티 + * 셀 값에 따른 스타일 계산 + */ + +import { PivotConditionalFormatRule } from "../../types"; + +// ==================== 타입 ==================== + +export interface CellFormatStyle { + backgroundColor?: string; + textColor?: string; + fontWeight?: string; + dataBarWidth?: number; // 0-100% + dataBarColor?: string; + icon?: string; // 이모지 또는 아이콘 이름 +} + +// ==================== 색상 유틸리티 ==================== + +/** + * HEX 색상을 RGB로 변환 + */ +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** + * RGB를 HEX로 변환 + */ +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((x) => { + const hex = Math.round(x).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join("") + ); +} + +/** + * 두 색상 사이의 보간 + */ +function interpolateColor( + color1: string, + color2: string, + factor: number +): string { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return color1; + + const r = rgb1.r + (rgb2.r - rgb1.r) * factor; + const g = rgb1.g + (rgb2.g - rgb1.g) * factor; + const b = rgb1.b + (rgb2.b - rgb1.b) * factor; + + return rgbToHex(r, g, b); +} + +// ==================== 조건부 서식 계산 ==================== + +/** + * Color Scale 스타일 계산 + */ +function applyColorScale( + value: number, + minValue: number, + maxValue: number, + rule: PivotConditionalFormatRule +): CellFormatStyle { + if (!rule.colorScale) return {}; + + const { minColor, midColor, maxColor } = rule.colorScale; + const range = maxValue - minValue; + + if (range === 0) { + return { backgroundColor: minColor }; + } + + const normalizedValue = (value - minValue) / range; + + let backgroundColor: string; + + if (midColor) { + // 3색 그라데이션 + if (normalizedValue <= 0.5) { + backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2); + } else { + backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2); + } + } else { + // 2색 그라데이션 + backgroundColor = interpolateColor(minColor, maxColor, normalizedValue); + } + + // 배경색에 따른 텍스트 색상 결정 + const rgb = hexToRgb(backgroundColor); + const textColor = + rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 + ? "#000000" + : "#ffffff"; + + return { backgroundColor, textColor }; +} + +/** + * Data Bar 스타일 계산 + */ +function applyDataBar( + value: number, + minValue: number, + maxValue: number, + rule: PivotConditionalFormatRule +): CellFormatStyle { + if (!rule.dataBar) return {}; + + const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar; + + const min = ruleMin ?? minValue; + const max = ruleMax ?? maxValue; + const range = max - min; + + if (range === 0) { + return { dataBarWidth: 100, dataBarColor: color }; + } + + const width = Math.max(0, Math.min(100, ((value - min) / range) * 100)); + + return { + dataBarWidth: width, + dataBarColor: color, + }; +} + +/** + * Icon Set 스타일 계산 + */ +function applyIconSet( + value: number, + minValue: number, + maxValue: number, + rule: PivotConditionalFormatRule +): CellFormatStyle { + if (!rule.iconSet) return {}; + + const { type, thresholds, reverse } = rule.iconSet; + const range = maxValue - minValue; + const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100; + + // 아이콘 정의 + const iconSets: Record = { + arrows: ["↓", "→", "↑"], + traffic: ["🔴", "🟡", "🟢"], + rating: ["⭐", "⭐⭐", "⭐⭐⭐"], + flags: ["🚩", "🏳️", "🏁"], + }; + + const icons = iconSets[type] || iconSets.arrows; + const sortedIcons = reverse ? [...icons].reverse() : icons; + + // 임계값에 따른 아이콘 선택 + let iconIndex = 0; + for (let i = 0; i < thresholds.length; i++) { + if (percentage >= thresholds[i]) { + iconIndex = i + 1; + } + } + iconIndex = Math.min(iconIndex, sortedIcons.length - 1); + + return { + icon: sortedIcons[iconIndex], + }; +} + +/** + * Cell Value 조건 스타일 계산 + */ +function applyCellValue( + value: number, + rule: PivotConditionalFormatRule +): CellFormatStyle { + if (!rule.cellValue) return {}; + + const { operator, value1, value2, backgroundColor, textColor, bold } = + rule.cellValue; + + let matches = false; + + switch (operator) { + case ">": + matches = value > value1; + break; + case ">=": + matches = value >= value1; + break; + case "<": + matches = value < value1; + break; + case "<=": + matches = value <= value1; + break; + case "=": + matches = value === value1; + break; + case "!=": + matches = value !== value1; + break; + case "between": + matches = value2 !== undefined && value >= value1 && value <= value2; + break; + } + + if (!matches) return {}; + + return { + backgroundColor, + textColor, + fontWeight: bold ? "bold" : undefined, + }; +} + +// ==================== 메인 함수 ==================== + +/** + * 조건부 서식 적용 + */ +export function getConditionalStyle( + value: number | null | undefined, + field: string, + rules: PivotConditionalFormatRule[], + allValues: number[] // 해당 필드의 모든 값 (min/max 계산용) +): CellFormatStyle { + if (value === null || value === undefined || isNaN(value)) { + return {}; + } + + if (!rules || rules.length === 0) { + return {}; + } + + // min/max 계산 + const numericValues = allValues.filter((v) => !isNaN(v)); + const minValue = Math.min(...numericValues); + const maxValue = Math.max(...numericValues); + + let resultStyle: CellFormatStyle = {}; + + // 해당 필드에 적용되는 규칙 필터링 및 적용 + for (const rule of rules) { + // 필드 필터 확인 + if (rule.field && rule.field !== field) { + continue; + } + + let ruleStyle: CellFormatStyle = {}; + + switch (rule.type) { + case "colorScale": + ruleStyle = applyColorScale(value, minValue, maxValue, rule); + break; + case "dataBar": + ruleStyle = applyDataBar(value, minValue, maxValue, rule); + break; + case "iconSet": + ruleStyle = applyIconSet(value, minValue, maxValue, rule); + break; + case "cellValue": + ruleStyle = applyCellValue(value, rule); + break; + } + + // 스타일 병합 (나중 규칙이 우선) + resultStyle = { ...resultStyle, ...ruleStyle }; + } + + return resultStyle; +} + +/** + * 조건부 서식 스타일을 React 스타일 객체로 변환 + */ +export function formatStyleToReact( + style: CellFormatStyle +): React.CSSProperties { + const result: React.CSSProperties = {}; + + if (style.backgroundColor) { + result.backgroundColor = style.backgroundColor; + } + if (style.textColor) { + result.color = style.textColor; + } + if (style.fontWeight) { + result.fontWeight = style.fontWeight as any; + } + + return result; +} + +export default getConditionalStyle; + diff --git a/frontend/lib/registry/components/table/utils/pivot/exportExcel.ts b/frontend/lib/registry/components/table/utils/pivot/exportExcel.ts new file mode 100644 index 00000000..62730907 --- /dev/null +++ b/frontend/lib/registry/components/table/utils/pivot/exportExcel.ts @@ -0,0 +1,202 @@ +/** + * Excel 내보내기 유틸리티 + * 피벗 테이블 데이터를 Excel 파일로 내보내기 + * xlsx 라이브러리 사용 (브라우저 호환) + */ + +import * as XLSX from "xlsx"; +import { + PivotResult, + PivotFieldConfig, + PivotTotalsConfig, +} from "../../types"; +import { pathToKey } from "./pivotEngine"; + +// ==================== 타입 ==================== + +export interface ExportOptions { + fileName?: string; + sheetName?: string; + title?: string; + subtitle?: string; + includeHeaders?: boolean; + includeTotals?: boolean; +} + +// ==================== 메인 함수 ==================== + +/** + * 피벗 데이터를 Excel로 내보내기 + */ +export async function exportPivotToExcel( + pivotResult: PivotResult, + fields: PivotFieldConfig[], + totals: PivotTotalsConfig, + options: ExportOptions = {} +): Promise { + const { + fileName = "pivot_export", + sheetName = "Pivot", + title, + includeHeaders = true, + includeTotals = true, + } = options; + + // 필드 분류 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + // 데이터 배열 생성 + const data: any[][] = []; + + // 제목 추가 + if (title) { + data.push([title]); + data.push([]); // 빈 행 + } + + // 헤더 행 + if (includeHeaders) { + const headerRow: any[] = [ + rowFields.map((f) => f.caption).join(" / ") || "항목", + ]; + + // 열 헤더 + for (const col of pivotResult.flatColumns) { + headerRow.push(col.caption || "(전체)"); + } + + // 총계 헤더 + if (totals?.showRowGrandTotals && includeTotals) { + headerRow.push("총계"); + } + + data.push(headerRow); + } + + // 데이터 행 + for (const row of pivotResult.flatRows) { + const excelRow: any[] = []; + + // 행 헤더 (들여쓰기 포함) + const indent = " ".repeat(row.level); + excelRow.push(indent + row.caption); + + // 데이터 셀 + for (const col of pivotResult.flatColumns) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = pivotResult.dataMatrix.get(cellKey); + + if (values && values.length > 0) { + excelRow.push(values[0].value); + } else { + excelRow.push(""); + } + } + + // 행 총계 + if (totals?.showRowGrandTotals && includeTotals) { + const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + excelRow.push(rowTotal[0].value); + } else { + excelRow.push(""); + } + } + + data.push(excelRow); + } + + // 열 총계 행 + if (totals?.showColumnGrandTotals && includeTotals) { + const totalRow: any[] = ["총계"]; + + for (const col of pivotResult.flatColumns) { + const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path)); + if (colTotal && colTotal.length > 0) { + totalRow.push(colTotal[0].value); + } else { + totalRow.push(""); + } + } + + // 대총합 + if (totals?.showRowGrandTotals) { + const grandTotal = pivotResult.grandTotals.grand; + if (grandTotal && grandTotal.length > 0) { + totalRow.push(grandTotal[0].value); + } else { + totalRow.push(""); + } + } + + data.push(totalRow); + } + + // 워크시트 생성 + const worksheet = XLSX.utils.aoa_to_sheet(data); + + // 컬럼 너비 설정 + const colWidths: XLSX.ColInfo[] = []; + const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0); + for (let i = 0; i < maxCols; i++) { + colWidths.push({ wch: i === 0 ? 25 : 15 }); + } + worksheet["!cols"] = colWidths; + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} + +/** + * Drill Down 데이터를 Excel로 내보내기 + */ +export async function exportDrillDownToExcel( + data: any[], + columns: { field: string; caption: string }[], + options: ExportOptions = {} +): Promise { + const { + fileName = "drilldown_export", + sheetName = "Data", + title, + } = options; + + // 데이터 배열 생성 + const sheetData: any[][] = []; + + // 제목 + if (title) { + sheetData.push([title]); + sheetData.push([]); // 빈 행 + } + + // 헤더 + const headerRow = columns.map((col) => col.caption); + sheetData.push(headerRow); + + // 데이터 + for (const row of data) { + const dataRow = columns.map((col) => row[col.field] ?? ""); + sheetData.push(dataRow); + } + + // 워크시트 생성 + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // 컬럼 너비 설정 + const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 })); + worksheet["!cols"] = colWidths; + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} diff --git a/frontend/lib/registry/components/table/utils/pivot/index.ts b/frontend/lib/registry/components/table/utils/pivot/index.ts new file mode 100644 index 00000000..2c0a83d6 --- /dev/null +++ b/frontend/lib/registry/components/table/utils/pivot/index.ts @@ -0,0 +1,6 @@ +export * from "./aggregation"; +export * from "./pivotEngine"; +export * from "./exportExcel"; +export * from "./conditionalFormat"; + + diff --git a/frontend/lib/registry/components/table/utils/pivot/pivotEngine.ts b/frontend/lib/registry/components/table/utils/pivot/pivotEngine.ts new file mode 100644 index 00000000..b43b80cf --- /dev/null +++ b/frontend/lib/registry/components/table/utils/pivot/pivotEngine.ts @@ -0,0 +1,812 @@ +/** + * PivotGrid 데이터 처리 엔진 + * 원시 데이터를 피벗 구조로 변환합니다. + */ + +import { + PivotFieldConfig, + PivotResult, + PivotHeaderNode, + PivotFlatRow, + PivotFlatColumn, + PivotCellValue, + PivotDateGroupInterval, + PivotAggregationType, + PivotSummaryDisplayMode, +} from "../../types"; +import { aggregate, formatNumber, formatDate } from "./aggregation"; + +// ==================== 헬퍼 함수 ==================== + +/** + * 필드 값 추출 (날짜 그룹핑 포함) + */ +function getFieldValue( + row: Record, + field: PivotFieldConfig +): string { + const rawValue = row[field.field]; + + if (rawValue === null || rawValue === undefined) { + return "(빈 값)"; + } + + // 날짜 그룹핑 처리 + if (field.groupInterval && field.dataType === "date") { + const date = new Date(rawValue); + if (isNaN(date.getTime())) return String(rawValue); + + switch (field.groupInterval) { + case "year": + return String(date.getFullYear()); + case "quarter": + return `Q${Math.ceil((date.getMonth() + 1) / 3)}`; + case "month": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + case "week": + const weekNum = getWeekNumber(date); + return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; + case "day": + return formatDate(date); + default: + return String(rawValue); + } + } + + return String(rawValue); +} + +/** + * 주차 계산 + */ +function getWeekNumber(date: Date): number { + const d = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +/** + * 경로를 키로 변환 + */ +export function pathToKey(path: string[]): string { + return path.join("||"); +} + +/** + * 키를 경로로 변환 + */ +export function keyToPath(key: string): string[] { + return key.split("||"); +} + +// ==================== 헤더 생성 ==================== + +/** + * 계층적 헤더 노드 생성 + */ +function buildHeaderTree( + data: Record[], + fields: PivotFieldConfig[], + expandedPaths: Set +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + // 첫 번째 필드로 그룹화 + const firstField = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, firstField); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + // 정렬 + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (firstField.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + // 노드 생성 + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: 0, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + // 자식 노드 생성 (확장된 경우만) + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + 1 + ); + // span 계산 + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * 자식 노드 재귀 생성 + */ +function buildChildNodes( + data: Record[], + fields: PivotFieldConfig[], + parentPath: string[], + expandedPaths: Set, + level: number +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + const field = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, field); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (field.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [...parentPath, key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: level, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + level + 1 + ); + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * span 계산 (colspan/rowspan) + */ +function calculateSpan(children?: PivotHeaderNode[]): number { + if (!children || children.length === 0) return 1; + return children.reduce((sum, child) => sum + (child.span ?? 1), 0); +} + +// ==================== 플랫 구조 변환 ==================== + +/** + * 헤더 트리를 플랫 행으로 변환 + */ +function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { + const result: PivotFlatRow[] = []; + + function traverse(node: PivotHeaderNode) { + result.push({ + path: node.path, + level: node.level, + caption: node.caption, + isExpanded: node.isExpanded, + hasChildren: !!(node.children && node.children.length > 0), + }); + + if (node.isExpanded && node.children) { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + return result; +} + +/** + * 헤더 트리를 플랫 열로 변환 (각 레벨별) + */ +function flattenColumns( + nodes: PivotHeaderNode[], + maxLevel: number +): PivotFlatColumn[][] { + const levels: PivotFlatColumn[][] = Array.from( + { length: maxLevel + 1 }, + () => [] + ); + + function traverse(node: PivotHeaderNode, currentLevel: number) { + levels[currentLevel].push({ + path: node.path, + level: currentLevel, + caption: node.caption, + span: node.span ?? 1, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, currentLevel + 1); + } + } else if (currentLevel < maxLevel) { + // 확장되지 않은 노드는 다음 레벨들에서 span으로 처리 + for (let i = currentLevel + 1; i <= maxLevel; i++) { + levels[i].push({ + path: node.path, + level: i, + caption: "", + span: node.span ?? 1, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + +/** + * 열 헤더의 최대 깊이 계산 + */ +function getMaxColumnLevel( + nodes: PivotHeaderNode[], + totalFields: number +): number { + let maxLevel = 0; + + function traverse(node: PivotHeaderNode, level: number) { + maxLevel = Math.max(maxLevel, level); + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return Math.min(maxLevel, totalFields - 1); +} + +// ==================== 데이터 매트릭스 생성 ==================== + +/** + * 데이터 매트릭스 생성 + */ +function buildDataMatrix( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): Map { + const matrix = new Map(); + + // 각 셀에 대해 해당하는 데이터 집계 + for (const row of flatRows) { + for (const colPath of flatColumnLeaves) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + + // 해당 행/열 경로에 맞는 데이터 필터링 + const filteredData = data.filter((record) => { + // 행 조건 확인 + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + + // 열 조건 확인 + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + + return true; + }); + + // 데이터 필드별 집계 + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate( + values, + dataField.summaryType || "sum" + ); + const formattedValue = formatNumber( + aggregatedValue, + dataField.format + ); + + return { + field: dataField.field, + value: aggregatedValue, + formattedValue, + }; + }); + + matrix.set(cellKey, cellValues); + } + } + + return matrix; +} + +/** + * 열 leaf 노드 경로 추출 + */ +function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { + const leaves: string[][] = []; + + function traverse(node: PivotHeaderNode) { + if (!node.isExpanded || !node.children || node.children.length === 0) { + leaves.push(node.path); + } else { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + // 열 필드가 없을 경우 빈 경로 추가 + if (leaves.length === 0) { + leaves.push([]); + } + + return leaves; +} + +// ==================== Summary Display Mode 적용 ==================== + +/** + * Summary Display Mode에 따른 값 변환 + */ +function applyDisplayMode( + value: number, + displayMode: PivotSummaryDisplayMode | undefined, + rowTotal: number, + columnTotal: number, + grandTotal: number, + prevValue: number | null, + runningTotal: number, + format?: PivotFieldConfig["format"] +): { value: number; formattedValue: string } { + if (!displayMode || displayMode === "absoluteValue") { + return { + value, + formattedValue: formatNumber(value, format), + }; + } + + let resultValue: number; + let formatOverride: PivotFieldConfig["format"] | undefined; + + switch (displayMode) { + case "percentOfRowTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfGrandTotal": + resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfRowGrandTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnGrandTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "runningTotalByRow": + case "runningTotalByColumn": + resultValue = runningTotal; + break; + + case "differenceFromPrevious": + resultValue = prevValue === null ? 0 : value - prevValue; + break; + + case "percentDifferenceFromPrevious": + resultValue = prevValue === null || prevValue === 0 + ? 0 + : ((value - prevValue) / Math.abs(prevValue)) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + default: + resultValue = value; + } + + return { + value: resultValue, + formattedValue: formatNumber(resultValue, formatOverride || format), + }; +} + +/** + * 데이터 매트릭스에 Summary Display Mode 적용 + */ +function applyDisplayModeToMatrix( + matrix: Map, + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][], + rowTotals: Map, + columnTotals: Map, + grandTotals: PivotCellValue[] +): Map { + // displayMode가 있는 데이터 필드가 있는지 확인 + const hasDisplayMode = dataFields.some( + (df) => df.summaryDisplayMode || df.showValuesAs + ); + if (!hasDisplayMode) return matrix; + + const newMatrix = new Map(); + + // 누계를 위한 추적 (행별, 열별) + const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 + const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> 누계 + + // 행 순서대로 처리 + for (const row of flatRows) { + // 이전 열 값 추적 (차이 계산용) + const prevColValues: (number | null)[] = dataFields.map(() => null); + + for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { + const colPath = flatColumnLeaves[colIdx]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + const values = matrix.get(cellKey); + + if (!values) { + newMatrix.set(cellKey, []); + continue; + } + + const rowKey = pathToKey(row.path); + const colKey = pathToKey(colPath); + + // 총합 가져오기 + const rowTotal = rowTotals.get(rowKey); + const colTotal = columnTotals.get(colKey); + + const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { + const dataField = dataFields[fieldIdx]; + const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; + + if (!displayMode || displayMode === "absoluteValue") { + prevColValues[fieldIdx] = val.value; + return val; + } + + // 누계 계산 + // 행 방향 누계 + if (!rowRunningTotals.has(rowKey)) { + rowRunningTotals.set(rowKey, dataFields.map(() => 0)); + } + const rowRunning = rowRunningTotals.get(rowKey)!; + rowRunning[fieldIdx] += val.value || 0; + + // 열 방향 누계 + if (!colRunningTotals.has(colKey)) { + colRunningTotals.set(colKey, new Map()); + } + const colRunning = colRunningTotals.get(colKey)!; + if (!colRunning.has(fieldIdx)) { + colRunning.set(fieldIdx, 0); + } + colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); + + const result = applyDisplayMode( + val.value || 0, + displayMode, + rowTotal?.[fieldIdx]?.value || 0, + colTotal?.[fieldIdx]?.value || 0, + grandTotals[fieldIdx]?.value || 0, + prevColValues[fieldIdx], + displayMode === "runningTotalByRow" + ? rowRunning[fieldIdx] + : colRunning.get(fieldIdx) || 0, + dataField.format + ); + + prevColValues[fieldIdx] = val.value; + + return { + field: val.field, + value: result.value, + formattedValue: result.formattedValue, + }; + }); + + newMatrix.set(cellKey, newValues); + } + } + + return newMatrix; +} + +// ==================== 총합계 계산 ==================== + +/** + * 총합계 계산 + */ +function calculateGrandTotals( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): { + row: Map; + column: Map; + grand: PivotCellValue[]; +} { + const rowTotals = new Map(); + const columnTotals = new Map(); + + // 행별 총합 (각 행의 모든 열 합계) + for (const row of flatRows) { + const filteredData = data.filter((record) => { + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + rowTotals.set(pathToKey(row.path), cellValues); + } + + // 열별 총합 (각 열의 모든 행 합계) + for (const colPath of flatColumnLeaves) { + const filteredData = data.filter((record) => { + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + columnTotals.set(pathToKey(colPath), cellValues); + } + + // 대총합 + const grandValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = data.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + return { + row: rowTotals, + column: columnTotals, + grand: grandValues, + }; +} + +// ==================== 메인 함수 ==================== + +/** + * 피벗 데이터 처리 + */ +export function processPivotData( + data: Record[], + fields: PivotFieldConfig[], + expandedRowPaths: string[][] = [], + expandedColumnPaths: string[][] = [] +): PivotResult { + // 영역별 필드 분리 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const columnFields = fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const dataFields = fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const filterFields = fields.filter( + (f) => f.area === "filter" && f.visible !== false + ); + + // 필터 적용 + let filteredData = data; + for (const filterField of filterFields) { + if (filterField.filterValues && filterField.filterValues.length > 0) { + filteredData = filteredData.filter((row) => { + const value = getFieldValue(row, filterField); + if (filterField.filterType === "exclude") { + return !filterField.filterValues!.includes(value); + } + return filterField.filterValues!.includes(value); + }); + } + } + + // 확장 경로 Set 변환 + const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); + const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + + // 기본 확장: 첫 번째 레벨 모두 확장 + if (expandedRowPaths.length === 0 && rowFields.length > 0) { + const firstField = rowFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedRowSet.add(val)); + } + + if (expandedColumnPaths.length === 0 && columnFields.length > 0) { + const firstField = columnFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedColSet.add(val)); + } + + // 헤더 트리 생성 + const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet); + const columnHeaders = buildHeaderTree( + filteredData, + columnFields, + expandedColSet + ); + + // 플랫 구조 변환 + const flatRows = flattenRows(rowHeaders); + const flatColumnLeaves = getColumnLeaves(columnHeaders); + const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length); + const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); + + // 데이터 매트릭스 생성 + let dataMatrix = buildDataMatrix( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // 총합계 계산 + const grandTotals = calculateGrandTotals( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // Summary Display Mode 적용 + dataMatrix = applyDisplayModeToMatrix( + dataMatrix, + dataFields, + flatRows, + flatColumnLeaves, + grandTotals.row, + grandTotals.column, + grandTotals.grand + ); + + return { + rowHeaders, + columnHeaders, + dataMatrix, + flatRows, + flatColumns: flatColumnLeaves.map((path, idx) => ({ + path, + level: path.length - 1, + caption: path[path.length - 1] || "", + span: 1, + })), + grandTotals, + }; +} + + diff --git a/frontend/lib/registry/components/table/views/PivotView.tsx b/frontend/lib/registry/components/table/views/PivotView.tsx index d7d759a9..26d56968 100644 --- a/frontend/lib/registry/components/table/views/PivotView.tsx +++ b/frontend/lib/registry/components/table/views/PivotView.tsx @@ -1,52 +1,1963 @@ "use client"; -import React from "react"; -import type { TableConfig } from "../types"; +/** + * PivotGrid 메인 컴포넌트 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ -export interface PivotViewProps { - config: TableConfig; - data: any[]; - isDesignMode?: boolean; +import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridProps, + PivotResult, + PivotFieldConfig, + PivotCellData, + PivotFlatRow, + PivotCellValue, + PivotGridState, +} from "../types"; +import { processPivotData, pathToKey } from "../utils/pivot/pivotEngine"; +import { exportPivotToExcel } from "../utils/pivot/exportExcel"; +import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "../utils/pivot/conditionalFormat"; +import { FieldPanel } from "../internals/pivot/components/FieldPanel"; +import { FieldChooser } from "../internals/pivot/components/FieldChooser"; +import { DrillDownModal } from "../internals/pivot/components/DrillDownModal"; +import { PivotChart } from "../internals/pivot/components/PivotChart"; +import { FilterPopup } from "../internals/pivot/components/FilterPopup"; +import { useVirtualScroll } from "../internals/pivot/hooks/useVirtualScroll"; +import { + ChevronRight, + ChevronDown, + Download, + Settings, + RefreshCw, + Maximize2, + Minimize2, + LayoutGrid, + FileSpreadsheet, + BarChart3, + Filter, + 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; // 병합된 셀에서 건너뛸지 여부 } -/** - * PivotView — displayMode="pivot" - * - * ⚠️ 통합 대기 — 다음 단계 (T3b) 에서 v2-pivot-grid 의 본체(1963줄) + - * utils/pivotEngine.ts 등 통째 흡수. 현재는 placeholder. - */ -export function PivotView({ config, data, isDesignMode = false }: PivotViewProps) { - const fieldsCount = config.pivotFields?.length ?? 0; - const rowsCount = config.pivotRows?.length ?? 0; - const colsCount = config.pivotColumns?.length ?? 0; - const valsCount = config.pivotValues?.length ?? 0; +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; +}; + +// ==================== 서브 컴포넌트 ==================== + +// 행 헤더 셀 +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; + + return ( + 1 ? rowSpan : undefined} + > +
+ {row.hasChildren && ( + + )} + {!row.hasChildren && } + {row.caption} +
+ + ); +}; + +// 데이터 셀 +interface DataCellProps { + values: PivotCellValue[]; + isTotal?: boolean; + isSelected?: boolean; + onClick?: (e?: React.MouseEvent) => void; + onDoubleClick?: () => void; + conditionalStyle?: CellFormatStyle; +} + +const DataCell: React.FC = ({ + values, + isTotal = false, + isSelected = false, + onClick, + onDoubleClick, + conditionalStyle, +}) => { + // 조건부 서식 스타일 계산 + const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; + const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; + const icon = conditionalStyle?.icon; + + // 선택 상태 스타일 + const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10"; + + if (!values || values.length === 0) { + return ( + + - + + ); + } + + // 툴팁 내용 생성 + const tooltipContent = values.map((v) => + `${v.field || "값"}: ${v.formattedValue || v.value}` + ).join("\n"); + + // 단일 데이터 필드인 경우 + if (values.length === 1) { + return ( + + {/* Data Bar */} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {values[0].formattedValue} + + + ); + } + + // 다중 데이터 필드인 경우 + return ( + <> + {values.map((val, idx) => ( + + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {val.formattedValue} + + + ))} + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotView: React.FC = ({ + title, + fields: initialFields = [], + totals = { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style = { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + fieldChooser, + chart: chartConfig, + allowExpandAll = true, + height = "auto", + maxHeight, + exportConfig, + data: externalData, + onCellClick, + onCellDoubleClick, + onFieldDrop, + onExpandChange, +}) => { + // 디버깅 로그 + console.log("🔶 PivotView props:", { + title, + hasExternalData: !!externalData, + externalDataLength: externalData?.length, + initialFieldsLength: initialFields?.length, + }); + + // 🆕 데이터 샘플 확인 + if (externalData && externalData.length > 0) { + console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); + console.log("🔶 전체 데이터 개수:", externalData.length); + } + + // 🆕 필드 설정 확인 + if (initialFields && initialFields.length > 0) { + console.log("🔶 필드 설정:", initialFields); + } + // ==================== 상태 ==================== + + const [fields, setFields] = useState(initialFields); + const [pivotState, setPivotState] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + + // 🆕 초기 로드 시 자동 확장 (첫 레벨만) + const [isInitialExpanded, setIsInitialExpanded] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태 + const [showFieldChooser, setShowFieldChooser] = useState(false); + const [drillDownData, setDrillDownData] = useState<{ + open: boolean; + cellData: PivotCellData | null; + }>({ open: false, cellData: null }); + const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + const [containerHeight, setContainerHeight] = useState(400); + const tableContainerRef = useRef(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); + + // 정렬 상태 + const [sortConfig, setSortConfig] = useState<{ + 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(() => { + if (initialFields.length > 0) { + setFields(initialFields); + } + }, [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 || []; + + // ==================== 필드 분류 ==================== + + 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] + ); + + // 필터 영역 필드 + const filterFields = useMemo( + () => + fields + .filter((f) => f.area === "filter" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [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]); + + // ==================== 필터 적용 ==================== + + const filteredData = useMemo(() => { + if (!data || data.length === 0) return data; + + // 필터 영역의 필드들로 데이터 필터링 + const activeFilters = fields.filter( + (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 + ); + + if (activeFilters.length === 0) return data; + + return data.filter((row) => { + return activeFilters.every((filter) => { + const value = row[filter.field]; + const filterValues = filter.filterValues || []; + const filterType = filter.filterType || "include"; + + if (filterType === "include") { + return filterValues.includes(value); + } else { + return !filterValues.includes(value); + } + }); + }); + }, [data, fields]); + + // ==================== 피벗 처리 ==================== + + const pivotResult = useMemo(() => { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { + return null; + } + + const visibleFields = fields.filter((f) => f.visible !== false); + // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) + if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { + return null; + } + + const result = processPivotData( + filteredData, + visibleFields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + + // 🆕 피벗 결과 확인 + 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; + }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // 🆕 초기 로드 시 첫 레벨 자동 확장 + useEffect(() => { + if (!isInitialExpanded && pivotResult && pivotResult.flatRows.length > 0) { + // 첫 레벨 행들의 경로 수집 (level 0인 행들) + const firstLevelPaths = pivotResult.flatRows + .filter(row => row.level === 0 && row.hasChildren) + .map(row => row.path); + + if (firstLevelPaths.length > 0) { + console.log("🔶 초기 자동 확장:", firstLevelPaths); + setPivotState(prev => ({ + ...prev, + expandedRowPaths: firstLevelPaths, + })); + setIsInitialExpanded(true); + } + } + }, [pivotResult, isInitialExpanded]); + + // 조건부 서식용 전체 값 수집 + const allCellValues = useMemo(() => { + if (!pivotResult) return new Map(); + + const valuesByField = new Map(); + + // 데이터 매트릭스에서 모든 값 수집 + 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]); + + // ==================== 가상 스크롤 ==================== + + 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(); + }, []); + + // 열 크기 조절 중 + 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 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: sortedFlatRows.length, + itemHeight: ROW_HEIGHT, + containerHeight: containerHeight, + overscan: 10, + }); + + // 가상 스크롤 적용된 행 데이터 + const visibleFlatRows = useMemo(() => { + if (!enableVirtualScroll) return sortedFlatRows; + return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + + // 조건부 서식 스타일 계산 헬퍼 + 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] + ); + + // ==================== 이벤트 핸들러 ==================== + + // 필드 변경 + const handleFieldsChange = useCallback( + (newFields: PivotFieldConfig[]) => { + setFields(newFields); + }, + [] + ); + + // 행 확장/축소 + const handleToggleRowExpand = useCallback( + (path: string[]) => { + console.log("🔶 행 확장/축소 클릭:", path); + + setPivotState((prev) => { + const pathKey = pathToKey(path); + const existingIndex = prev.expandedRowPaths.findIndex( + (p) => pathToKey(p) === pathKey + ); + + let newPaths: string[][]; + if (existingIndex >= 0) { + console.log("🔶 행 축소:", path); + newPaths = prev.expandedRowPaths.filter( + (_, i) => i !== existingIndex + ); + } else { + console.log("🔶 행 확장:", path); + newPaths = [...prev.expandedRowPaths, path]; + } + + console.log("🔶 새로운 확장 경로:", newPaths); + onExpandChange?.(newPaths); + + return { + ...prev, + expandedRowPaths: newPaths, + }; + }); + }, + [onExpandChange] + ); + + // 전체 확장 + const handleExpandAll = useCallback(() => { + if (!pivotResult) return; + + const allRowPaths: string[][] = []; + pivotResult.flatRows.forEach((row) => { + if (row.hasChildren) { + allRowPaths.push(row.path); + } + }); + + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + }, [pivotResult]); + + // 전체 축소 + const handleCollapseAll = useCallback(() => { + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); + }, []); + + // 셀 클릭 + 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] + ); + + // 셀 더블클릭 (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] + ); + + // 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]); + + // 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]); + + // 인쇄 기능 (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()); + }, []); + + // ==================== 렌더링 ==================== + + // 빈 상태 + if (!data || data.length === 0) { + return ( +
+ +

데이터가 없습니다

+

데이터를 로드하거나 필드를 설정해주세요

+
+ ); + } + + // 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인) + const hasActiveFields = fields.some( + (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) + ); + if (!hasActiveFields) { + return ( +
+ {/* 필드 패널 */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 안내 메시지 */} +
+ +

필드가 설정되지 않았습니다

+

+ 행, 열, 데이터 영역에 필드를 배치해주세요 +

+ +
+ + {/* 필드 선택기 모달 */} + +
+ ); + } + + // 피벗 결과 없음 + if (!pivotResult) { + return ( +
+ +
+ ); + } + + 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); + 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; + } + + if (newRowIndex !== rowIndex || newColIndex !== colIndex) { + setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); + } + }; + + // 셀 클릭으로 선택 (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); + }); + }; + + // 정렬 토글 + 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 ; + } + if (sortConfig.direction === "asc") { + return ; + } + return ; + }; return (
-
피벗 (구현 대기 중)
-
- 설정된 필드: pivotFields {fieldsCount} · pivotRows {rowsCount} · pivotColumns {colsCount} · - pivotValues {valsCount} + {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 헤더 툴바 */} +
+
+ {title &&

{title}

} + + ({filteredData.length !== data.length + ? `${filteredData.length} / ${data.length}건` + : `${data.length}건`}) + +
+ +
+ {/* 필드 선택기 버튼 */} + {fieldChooser?.enabled !== false && ( + + )} + + {/* 필드 패널 토글 */} + + + {allowExpandAll && ( + <> + + + + + )} + + {/* 차트 토글 */} + {chartConfig && ( + + )} + + {/* 내보내기 버튼들 */} + {exportConfig?.excel && ( + <> + + + + + + )} + + + + + + + + {/* 숨겨진 필드 표시 드롭다운 */} + {hiddenFieldsList.length > 0 && ( +
+ +
+
+ 숨겨진 필드 +
+
+ {hiddenFieldsList.map((field) => ( + + ))} +
+
+ +
+
+
+ )} + + +
-
데이터 행: {data.length}
- {isDesignMode && ( -
- [디자인 모드] 다음 단계에서 v2-pivot-grid 의 pivotEngine + 본체를 흡수해 실제 피벗 그리드를 렌더합니다. + + {/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */} + {filterFields.length > 0 && ( +
+ + 필터: +
+ {filterFields.map((filterField) => { + const selectedValues = filterField.filterValues || []; + const isFiltered = selectedValues.length > 0; + + return ( + { + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, filterValues: values, filterType: type } + : f + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ); + })} +
)} + + {/* 피벗 테이블 */} +
+ + + {/* 열 헤더 */} + + {/* 좌상단 코너 (행 필드 라벨 + 필터) */} + + + {/* 열 헤더 셀 */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 열 필드 필터 (헤더 왼쪽에 표시) */} + {columnFields.length > 0 && ( + + )} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + + {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} + {dataFields.length > 1 && ( + + {flatColumns.map((col, colIdx) => ( + + {dataFields.map((df, dfIdx) => ( + + ))} + + ))} + {totals?.showRowGrandTotals && + dataFields.map((df, dfIdx) => ( + + ))} + + )} + + + + {/* 열 총계 행 (상단 위치) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + + )} + + )} + + {/* 가상 스크롤 상단 여백 */} + {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( + + + )} + + {(() => { + // 셀 병합 정보 계산 + const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false); + + return visibleFlatRows.map((row, idx) => { + // 실제 행 인덱스 계산 + const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false }; + + return ( + + {/* 행 헤더 (병합되면 skip) */} + {!cellMerge.skip && ( + + )} + + {/* 데이터 셀 */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + // 조건부 서식 (첫 번째 값 기준) + const conditionalStyle = + values.length > 0 && values[0].field + ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) + : undefined; + + // 선택 상태 확인 (범위 선택 포함) + const isCellSelected = isCellInRange(rowIdx, colIdx); + + return ( + { + handleCellSelect(rowIdx, colIdx, e?.shiftKey || false); + if (onCellClick) { + handleCellClick(row.path, col.path, values); + } + }} + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* 행 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ); + }); + })()} + + {/* 가상 스크롤 하단 여백 */} + {enableVirtualScroll && ( + + + )} + + {/* 열 총계 행 (하단 위치 - 기본값) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + + )} + + )} + +
0 ? 2 : 1} + > +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
+ ))} + {rowFields.length === 0 && 항목} +
+
handleSort(dataFields[0].field) : undefined} + > +
+ {col.caption || "(전체)"} + {dataFields.length === 1 && } +
+ {/* 열 리사이즈 핸들 */} +
handleResizeStart(idx, e)} + /> +
0 ? 2 : 1} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
+ 총계 +
handleSort(df.field)} + > +
+ {df.caption} + +
+
+ {df.caption} +
+ 총계 +
+
+
+ 총계 +
+
+ + {/* 차트 */} + {showChart && chartConfig && pivotResult && ( + + )} + + {/* 필드 선택기 모달 */} + + + {/* Drill Down 모달 */} + setDrillDownData((prev) => ({ ...prev, open }))} + cellData={drillDownData.cellData} + data={data} + fields={fields} + rowFields={rowFields} + columnFields={columnFields} + />
); -} +}; + +export default PivotView;