diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index e7904a95..b0e8d207 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -5,7 +5,7 @@ * 다차원 데이터 분석을 위한 피벗 테이블 */ -import React, { useState, useMemo, useCallback, useEffect } from "react"; +import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, @@ -15,7 +15,6 @@ import { PivotFlatRow, PivotCellValue, PivotGridState, - PivotAreaType, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; import { exportPivotToExcel } from "./utils/exportExcel"; @@ -24,6 +23,8 @@ import { FieldPanel } from "./components/FieldPanel"; import { FieldChooser } from "./components/FieldChooser"; import { DrillDownModal } from "./components/DrillDownModal"; import { PivotChart } from "./components/PivotChart"; +import { FilterPopup } from "./components/FilterPopup"; +import { useVirtualScroll } from "./hooks/useVirtualScroll"; import { ChevronRight, ChevronDown, @@ -35,6 +36,10 @@ import { LayoutGrid, FileSpreadsheet, BarChart3, + Filter, + ArrowUp, + ArrowDown, + ArrowUpDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -88,6 +93,7 @@ const RowHeaderCell: React.FC = ({ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; + isSelected?: boolean; onClick?: () => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; @@ -96,6 +102,7 @@ interface DataCellProps { const DataCell: React.FC = ({ values, isTotal = false, + isSelected = false, onClick, onDoubleClick, conditionalStyle, @@ -104,6 +111,9 @@ const DataCell: React.FC = ({ const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; const icon = conditionalStyle?.icon; + + // 선택 상태 스타일 + const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10"; if (!values || values.length === 0) { return ( @@ -111,7 +121,8 @@ const DataCell: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1.5 text-right text-sm", - isTotal && "bg-primary/5 font-medium" + isTotal && "bg-primary/5 font-medium", + selectedClass )} style={cellStyle} onClick={onClick} @@ -130,7 +141,8 @@ const DataCell: React.FC = ({ "border-r border-b border-border relative", "px-2 py-1.5 text-right text-sm tabular-nums", isTotal && "bg-primary/5 font-medium", - (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50" + (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50", + selectedClass )} style={cellStyle} onClick={onClick} @@ -164,7 +176,8 @@ const DataCell: React.FC = ({ "border-r border-b border-border relative", "px-2 py-1.5 text-right text-sm tabular-nums", isTotal && "bg-primary/5 font-medium", - (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50" + (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50", + selectedClass )} style={cellStyle} onClick={onClick} @@ -237,13 +250,28 @@ export const PivotGridComponent: React.FC = ({ filterConfig: {}, }); const [isFullscreen, setIsFullscreen] = useState(false); - const [showFieldPanel, setShowFieldPanel] = useState(true); + const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태 const [showFieldChooser, setShowFieldChooser] = useState(false); const [drillDownData, setDrillDownData] = useState<{ open: boolean; cellData: PivotCellData | null; }>({ open: false, cellData: null }); const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + const [containerHeight, setContainerHeight] = useState(400); + const tableContainerRef = useRef(null); + + // 셀 선택 상태 + const [selectedCell, setSelectedCell] = useState<{ + rowIndex: number; + colIndex: number; + } | null>(null); + const tableRef = useRef(null); + + // 정렬 상태 + const [sortConfig, setSortConfig] = useState<{ + field: string; + direction: "asc" | "desc"; + } | null>(null); // 외부 fields 변경 시 동기화 useEffect(() => { @@ -281,6 +309,7 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + // 필터 영역 필드 const filterFields = useMemo( () => fields @@ -318,25 +347,53 @@ export const PivotGridComponent: React.FC = ({ }); }, [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 (!data || data.length === 0 || fields.length === 0) { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { return null; } const visibleFields = fields.filter((f) => f.visible !== false); - if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) + if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { return null; } return processPivotData( - data, + filteredData, visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); - }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // 조건부 서식용 전체 값 수집 const allCellValues = useMemo(() => { @@ -380,6 +437,42 @@ export const PivotGridComponent: React.FC = ({ return valuesByField; }, [pivotResult]); + // ==================== 가상 스크롤 ==================== + + const ROW_HEIGHT = 32; // 행 높이 (px) + const VIRTUAL_SCROLL_THRESHOLD = 50; // 이 행 수 이상이면 가상 스크롤 활성화 + + // 컨테이너 높이 측정 + useEffect(() => { + if (!tableContainerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(tableContainerRef.current); + return () => observer.disconnect(); + }, []); + + // 가상 스크롤 훅 사용 + const flatRows = pivotResult?.flatRows || []; + const enableVirtualScroll = flatRows.length > VIRTUAL_SCROLL_THRESHOLD; + + const virtualScroll = useVirtualScroll({ + itemCount: flatRows.length, + itemHeight: ROW_HEIGHT, + containerHeight: containerHeight, + overscan: 10, + }); + + // 가상 스크롤 적용된 행 데이터 + const visibleFlatRows = useMemo(() => { + if (!enableVirtualScroll) return flatRows; + return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + // 조건부 서식 스타일 계산 헬퍼 const getCellConditionalStyle = useCallback( (value: number | undefined, field: string): CellFormatStyle => { @@ -587,9 +680,9 @@ export const PivotGridComponent: React.FC = ({ ); } - // 필드 미설정 + // 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인) const hasActiveFields = fields.some( - (f) => f.visible !== false && f.area !== "filter" + (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) ); if (!hasActiveFields) { return ( @@ -646,7 +739,125 @@ export const PivotGridComponent: React.FC = ({ ); } - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + const { flatColumns, dataMatrix, grandTotals } = pivotResult; + + // ==================== 키보드 네비게이션 ==================== + + // 키보드 핸들러 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!selectedCell) return; + + const { rowIndex, colIndex } = selectedCell; + const maxRowIndex = visibleFlatRows.length - 1; + const maxColIndex = flatColumns.length - 1; + + let newRowIndex = rowIndex; + let newColIndex = colIndex; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 1); + break; + case "ArrowDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 1); + break; + case "ArrowLeft": + e.preventDefault(); + newColIndex = Math.max(0, colIndex - 1); + break; + case "ArrowRight": + e.preventDefault(); + newColIndex = Math.min(maxColIndex, colIndex + 1); + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = 0; + newColIndex = 0; + } else { + newColIndex = 0; + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = maxRowIndex; + newColIndex = maxColIndex; + } else { + newColIndex = maxColIndex; + } + break; + case "PageUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 10); + break; + case "PageDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 10); + break; + case "Enter": + e.preventDefault(); + // 셀 더블클릭과 동일한 동작 (드릴다운) + if (visibleFlatRows[rowIndex] && flatColumns[colIndex]) { + const row = visibleFlatRows[rowIndex]; + const col = flatColumns[colIndex]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + // 드릴다운 모달 열기 + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath: row.path, + columnPath: col.path, + field: values[0]?.field, + }; + setDrillDownData({ open: true, cellData }); + } + break; + case "Escape": + e.preventDefault(); + setSelectedCell(null); + break; + default: + return; + } + + if (newRowIndex !== rowIndex || newColIndex !== colIndex) { + setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); + } + }; + + // 셀 클릭으로 선택 + const handleCellSelect = (rowIndex: number, colIndex: number) => { + setSelectedCell({ rowIndex, colIndex }); + }; + + // 정렬 토글 + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (prev?.field === field) { + // 같은 필드 클릭: asc -> desc -> null 순환 + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; // 정렬 해제 + } + // 새로운 필드: asc로 시작 + return { field, direction: "asc" }; + }); + }; + + // 정렬 아이콘 렌더링 + const SortIcon = ({ field }: { field: string }) => { + if (sortConfig?.field !== field) { + return ; + } + if (sortConfig.direction === "asc") { + return ; + } + return ; + }; return (
= ({
{title &&

{title}

} - ({data.length}건) + ({filteredData.length !== data.length + ? `${filteredData.length} / ${data.length}건` + : `${data.length}건`})
@@ -780,13 +993,68 @@ export const PivotGridComponent: React.FC = ({
+ {/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */} + {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={ + + } + /> + ); + })} +
+
+ )} + {/* 피벗 테이블 */} -
- +
+
{/* 열 헤더 */} - {/* 좌상단 코너 (행 필드 라벨) */} + {/* 좌상단 코너 (행 필드 라벨 + 필터) */} {/* 열 헤더 셀 */} @@ -805,13 +1104,59 @@ export const PivotGridComponent: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1.5 text-center text-xs font-medium", - "bg-muted/70 sticky top-0 z-10" + "bg-muted/70 sticky top-0 z-10", + dataFields.length === 1 && "cursor-pointer hover:bg-accent/50" )} colSpan={dataFields.length || 1} + onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} > - {col.caption || "(전체)"} +
+ {col.caption || "(전체)"} + {dataFields.length === 1 && } +
))} + + {/* 열 필드 필터 (헤더 왼쪽에 표시) */} + {columnFields.length > 0 && ( +
+ )} {/* 행 총계 헤더 */} {totals?.showRowGrandTotals && ( @@ -839,10 +1184,14 @@ export const PivotGridComponent: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1 text-center text-xs font-normal", - "text-muted-foreground" + "text-muted-foreground cursor-pointer hover:bg-accent/50" )} + onClick={() => handleSort(df.field)} > - {df.caption} +
+ {df.caption} + +
))} @@ -865,59 +1214,84 @@ export const PivotGridComponent: React.FC = ({
- {flatRows.map((row, rowIdx) => ( - - {/* 행 헤더 */} - - - {/* 데이터 셀 */} - {flatColumns.map((col, colIdx) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey) || []; - - // 조건부 서식 (첫 번째 값 기준) - const conditionalStyle = - values.length > 0 && values[0].field - ? getCellConditionalStyle(values[0].value, values[0].field) - : undefined; - - return ( - handleCellClick(row.path, col.path, values) - : undefined - } - onDoubleClick={() => - handleCellDoubleClick(row.path, col.path, values) - } - /> - ); - })} - - {/* 행 총계 */} - {totals?.showRowGrandTotals && ( - - )} + {/* 가상 스크롤 상단 여백 */} + {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( + + - ))} + )} + + {visibleFlatRows.map((row, idx) => { + // 실제 행 인덱스 계산 + const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + + return ( + + {/* 행 헤더 */} + + + {/* 데이터 셀 */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + // 조건부 서식 (첫 번째 값 기준) + const conditionalStyle = + values.length > 0 && values[0].field + ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) + : undefined; + + // 선택 상태 확인 + const isCellSelected = selectedCell?.rowIndex === rowIdx && selectedCell?.colIndex === colIdx; + + return ( + { + handleCellSelect(rowIdx, colIdx); + if (onCellClick) { + handleCellClick(row.path, col.path, values); + } + }} + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* 행 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ); + })} + + {/* 가상 스크롤 하단 여백 */} + {enableVirtualScroll && ( + + + )} {/* 열 총계 행 */} {totals?.showColumnGrandTotals && ( diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index f3e9a976..ba691afa 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -1,8 +1,11 @@ "use client"; /** - * PivotGrid 설정 패널 - * 화면 관리에서 PivotGrid 컴포넌트를 설정하는 UI + * PivotGrid 설정 패널 - 간소화 버전 + * + * 피벗 테이블 설정 방법: + * 1. 테이블 선택 + * 2. 컬럼을 드래그하여 행/열/값 영역에 배치 */ import React, { useState, useEffect, useCallback } from "react"; @@ -12,14 +15,12 @@ import { PivotFieldConfig, PivotAreaType, AggregationType, - DateGroupInterval, FieldDataType, } from "./types"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -29,24 +30,20 @@ import { SelectValue, } from "@/components/ui/select"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { - Plus, - Trash2, - GripVertical, - Settings2, Rows, Columns, - Database, - Filter, - ChevronUp, + Calculator, + X, + Plus, + GripVertical, + Table2, + BarChart3, + Settings, ChevronDown, + ChevronUp, + Info, } from "lucide-react"; -import { apiClient } from "@/lib/api/client"; +import { tableTypeApi } from "@/lib/api/screen"; // ==================== 타입 ==================== @@ -59,7 +56,6 @@ interface ColumnInfo { column_name: string; data_type: string; column_comment?: string; - is_nullable: string; } interface PivotGridConfigPanelProps { @@ -67,57 +63,13 @@ interface PivotGridConfigPanelProps { onChange: (config: PivotGridComponentConfig) => void; } -// ==================== 유틸리티 ==================== - -const AREA_LABELS: Record = { - row: { label: "행 영역", icon: }, - column: { label: "열 영역", icon: }, - data: { label: "데이터 영역", icon: }, - filter: { label: "필터 영역", icon: }, -}; - -const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ - { value: "sum", label: "합계" }, - { value: "count", label: "개수" }, - { value: "avg", label: "평균" }, - { value: "min", label: "최소" }, - { value: "max", label: "최대" }, - { value: "countDistinct", label: "고유값 개수" }, -]; - -const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [ - { value: "year", label: "연도" }, - { value: "quarter", label: "분기" }, - { value: "month", label: "월" }, - { value: "week", label: "주" }, - { value: "day", label: "일" }, -]; - -const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [ - { value: "string", label: "문자열" }, - { value: "number", label: "숫자" }, - { value: "date", label: "날짜" }, - { value: "boolean", label: "부울" }, -]; - // DB 타입을 FieldDataType으로 변환 function mapDbTypeToFieldType(dbType: string): FieldDataType { const type = dbType.toLowerCase(); - if ( - type.includes("int") || - type.includes("numeric") || - type.includes("decimal") || - type.includes("float") || - type.includes("double") || - type.includes("real") - ) { + if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) { return "number"; } - if ( - type.includes("date") || - type.includes("time") || - type.includes("timestamp") - ) { + if (type.includes("date") || type.includes("time") || type.includes("timestamp")) { return "date"; } if (type.includes("bool")) { @@ -126,332 +78,174 @@ function mapDbTypeToFieldType(dbType: string): FieldDataType { return "string"; } -// ==================== 필드 설정 컴포넌트 ==================== +// ==================== 컬럼 칩 컴포넌트 ==================== -interface FieldConfigItemProps { - field: PivotFieldConfig; - index: number; - onChange: (field: PivotFieldConfig) => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; - isFirst: boolean; - isLast: boolean; +interface ColumnChipProps { + column: ColumnInfo; + isUsed: boolean; + onClick: () => void; } -const FieldConfigItem: React.FC = ({ - field, - index, - onChange, - onRemove, - onMoveUp, - onMoveDown, - isFirst, - isLast, -}) => { +const ColumnChip: React.FC = ({ column, isUsed, onClick }) => { + const dataType = mapDbTypeToFieldType(column.data_type); + const typeColor = { + number: "bg-blue-100 text-blue-700 border-blue-200", + string: "bg-green-100 text-green-700 border-green-200", + date: "bg-purple-100 text-purple-700 border-purple-200", + boolean: "bg-orange-100 text-orange-700 border-orange-200", + }[dataType]; + return ( -
- {/* 드래그 핸들 & 순서 버튼 */} -
- - - -
- - {/* 필드 설정 */} -
- {/* 필드명 & 라벨 */} -
-
- - onChange({ ...field, field: e.target.value })} - placeholder="column_name" - className="h-8 text-xs" - /> -
-
- - onChange({ ...field, caption: e.target.value })} - placeholder="표시명" - className="h-8 text-xs" - /> -
-
- - {/* 데이터 타입 & 집계 함수 */} -
-
- - -
- - {field.area === "data" && ( -
- - -
- )} - - {field.dataType === "date" && - (field.area === "row" || field.area === "column") && ( -
- - -
- )} -
-
- - {/* 삭제 버튼 */} - -
+ ); }; -// ==================== 영역별 필드 목록 ==================== +// ==================== 영역 드롭존 컴포넌트 ==================== -interface AreaFieldListProps { +interface AreaDropZoneProps { area: PivotAreaType; + label: string; + description: string; + icon: React.ReactNode; fields: PivotFieldConfig[]; - allColumns: ColumnInfo[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; + columns: ColumnInfo[]; + onAddField: (column: ColumnInfo) => void; + onRemoveField: (index: number) => void; + onUpdateField: (index: number, updates: Partial) => void; + color: string; } -const AreaFieldList: React.FC = ({ +const AreaDropZone: React.FC = ({ area, + label, + description, + icon, fields, - allColumns, - onFieldsChange, + columns, + onAddField, + onRemoveField, + onUpdateField, + color, }) => { - const areaFields = fields.filter((f) => f.area === area); - const { label, icon } = AREA_LABELS[area]; - - const handleAddField = () => { - const newField: PivotFieldConfig = { - field: "", - caption: "", - area, - areaIndex: areaFields.length, - dataType: "string", - visible: true, - }; - if (area === "data") { - newField.summaryType = "sum"; - } - onFieldsChange([...fields, newField]); - }; - - const handleAddFromColumn = (column: ColumnInfo) => { - const dataType = mapDbTypeToFieldType(column.data_type); - const newField: PivotFieldConfig = { - field: column.column_name, - caption: column.column_comment || column.column_name, - area, - areaIndex: areaFields.length, - dataType, - visible: true, - }; - if (area === "data") { - newField.summaryType = "sum"; - } - onFieldsChange([...fields, newField]); - }; - - const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => { - const newFields = [...fields]; - const globalIndex = fields.findIndex( - (f) => f.area === area && f.areaIndex === index - ); - if (globalIndex >= 0) { - newFields[globalIndex] = updatedField; - onFieldsChange(newFields); - } - }; - - const handleRemoveField = (index: number) => { - const newFields = fields.filter( - (f) => !(f.area === area && f.areaIndex === index) - ); - // 인덱스 재정렬 - let idx = 0; - newFields.forEach((f) => { - if (f.area === area) { - f.areaIndex = idx++; - } - }); - onFieldsChange(newFields); - }; - - const handleMoveField = (fromIndex: number, direction: "up" | "down") => { - const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; - if (toIndex < 0 || toIndex >= areaFields.length) return; - - const newAreaFields = [...areaFields]; - const [moved] = newAreaFields.splice(fromIndex, 1); - newAreaFields.splice(toIndex, 0, moved); - - // 인덱스 재정렬 - newAreaFields.forEach((f, idx) => { - f.areaIndex = idx; - }); - - // 전체 필드 업데이트 - const newFields = fields.filter((f) => f.area !== area); - onFieldsChange([...newFields, ...newAreaFields]); - }; - - // 이미 추가된 컬럼 제외 - const availableColumns = allColumns.filter( + const [isExpanded, setIsExpanded] = useState(true); + + // 사용 가능한 컬럼 (이미 추가된 컬럼 제외) + const availableColumns = columns.filter( (col) => !fields.some((f) => f.field === col.column_name) ); return ( - - +
+ {/* 헤더 */} +
setIsExpanded(!isExpanded)} + >
{icon} - {label} - - {areaFields.length} + {label} + + {fields.length}
- - - {/* 필드 목록 */} - {areaFields - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) - .map((field, idx) => ( - handleFieldChange(field.areaIndex || idx, f)} - onRemove={() => handleRemoveField(field.areaIndex || idx)} - onMoveUp={() => handleMoveField(idx, "up")} - onMoveDown={() => handleMoveField(idx, "down")} - isFirst={idx === 0} - isLast={idx === areaFields.length - 1} - /> - ))} + {isExpanded ? : } +
+ + {/* 설명 */} +

{description}

- {/* 필드 추가 */} -
- onUpdateField(idx, { summaryType: v as AggregationType })} + > + + + + + 합계 + 개수 + 평균 + 최소 + 최대 + + + )} + + +
+ ))} +
+ ) : ( +
+ 아래에서 컬럼을 선택하세요 +
+ )} + + {/* 컬럼 추가 드롭다운 */} + {availableColumns.length > 0 && ( + - - + ))} + + + )} - -
+ )} + ); }; @@ -465,17 +259,19 @@ export const PivotGridConfigPanel: React.FC = ({ const [columns, setColumns] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); // 테이블 목록 로드 useEffect(() => { const loadTables = async () => { setLoadingTables(true); try { - // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 - const response = await apiClient.get("/table-management/tables"); - if (response.data.success) { - setTables(response.data.data || []); - } + const tableList = await tableTypeApi.getTables(); + const mappedTables: TableInfo[] = tableList.map((t: any) => ({ + table_name: t.tableName, + table_comment: t.tableLabel || t.displayName || t.tableName, + })); + setTables(mappedTables); } catch (error) { console.error("테이블 목록 로드 실패:", error); } finally { @@ -495,13 +291,13 @@ export const PivotGridConfigPanel: React.FC = ({ setLoadingColumns(true); try { - // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 - const response = await apiClient.get( - `/table-management/tables/${config.dataSource.tableName}/columns` - ); - if (response.data.success) { - setColumns(response.data.data || []); - } + const columnList = await tableTypeApi.getColumns(config.dataSource.tableName); + const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ + column_name: c.columnName || c.column_name, + data_type: c.dataType || c.data_type || "text", + column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name, + })); + setColumns(mappedColumns); } catch (error) { console.error("컬럼 목록 로드 실패:", error); } finally { @@ -519,489 +315,288 @@ export const PivotGridConfigPanel: React.FC = ({ [config, onChange] ); + // 필드 추가 + const handleAddField = (area: PivotAreaType, column: ColumnInfo) => { + const currentFields = config.fields || []; + const areaFields = currentFields.filter(f => f.area === area); + + const newField: PivotFieldConfig = { + field: column.column_name, + caption: column.column_comment || column.column_name, + area, + areaIndex: areaFields.length, + dataType: mapDbTypeToFieldType(column.data_type), + visible: true, + }; + + if (area === "data") { + newField.summaryType = "sum"; + } + + updateConfig({ fields: [...currentFields, newField] }); + }; + + // 필드 제거 + const handleRemoveField = (area: PivotAreaType, index: number) => { + const currentFields = config.fields || []; + const newFields = currentFields.filter( + (f) => !(f.area === area && f.areaIndex === index) + ); + + // 인덱스 재정렬 + let idx = 0; + newFields.forEach((f) => { + if (f.area === area) { + f.areaIndex = idx++; + } + }); + + updateConfig({ fields: newFields }); + }; + + // 필드 업데이트 + const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial) => { + const currentFields = config.fields || []; + const newFields = currentFields.map((f) => { + if (f.area === area && f.areaIndex === index) { + return { ...f, ...updates }; + } + return f; + }); + updateConfig({ fields: newFields }); + }; + + // 영역별 필드 가져오기 + const getFieldsByArea = (area: PivotAreaType) => { + return (config.fields || []) + .filter(f => f.area === area) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + }; + return (
- {/* 데이터 소스 설정 */} -
- - -
- - + {/* 사용 가이드 */} +
+
+ +
+

피벗 테이블 설정 방법

+
    +
  1. 데이터를 가져올 테이블을 선택하세요
  2. +
  3. 행 그룹에 그룹화할 컬럼을 추가하세요 (예: 지역, 부서)
  4. +
  5. 열 그룹에 가로로 펼칠 컬럼을 추가하세요 (예: 월, 분기)
  6. +
  7. 에 집계할 숫자 컬럼을 추가하세요 (예: 매출, 수량)
  8. +
+
- + {/* STEP 1: 테이블 선택 */} +
+
+ + +
+ + +
- {/* 필드 설정 */} + {/* STEP 2: 필드 배치 */} {config.dataSource?.tableName && (
-
- - - {columns.length}개 컬럼 - +
+ + + {loadingColumns && (컬럼 로딩 중...)}
- {loadingColumns ? ( -
- 컬럼 로딩 중... + {/* 사용 가능한 컬럼 목록 */} + {columns.length > 0 && ( +
+ +
+ {columns.map((col) => { + const isUsed = (config.fields || []).some(f => f.field === col.column_name); + return ( + {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}} + /> + ); + })} +
- ) : ( - - {(["row", "column", "data", "filter"] as PivotAreaType[]).map( - (area) => ( - updateConfig({ fields })} - /> - ) - )} - )} + + {/* 영역별 드롭존 */} +
+ } + fields={getFieldsByArea("row")} + columns={columns} + onAddField={(col) => handleAddField("row", col)} + onRemoveField={(idx) => handleRemoveField("row", idx)} + onUpdateField={(idx, updates) => handleUpdateField("row", idx, updates)} + color="border-emerald-200 bg-emerald-50/50" + /> + + } + fields={getFieldsByArea("column")} + columns={columns} + onAddField={(col) => handleAddField("column", col)} + onRemoveField={(idx) => handleRemoveField("column", idx)} + onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)} + color="border-blue-200 bg-blue-50/50" + /> + + } + fields={getFieldsByArea("data")} + columns={columns} + onAddField={(col) => handleAddField("data", col)} + onRemoveField={(idx) => handleRemoveField("data", idx)} + onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)} + color="border-amber-200 bg-amber-50/50" + /> +
)} - - - {/* 표시 설정 */} -
- - -
-
- - - updateConfig({ - totals: { ...config.totals, showRowGrandTotals: v }, - }) - } - /> + {/* 고급 설정 토글 */} +
+
- -
- - - updateConfig({ - style: { ...config.style, alternateRowColors: v }, - }) - } - /> -
- -
- - - updateConfig({ - style: { ...config.style, highlightTotals: v }, - }) - } - /> -
+ {showAdvanced ? : } +
- - - {/* 기능 설정 */} -
- - -
-
- - - updateConfig({ allowExpandAll: v }) - } - /> -
- -
- - - updateConfig({ - exportConfig: { ...config.exportConfig, excel: v }, - }) - } - /> -
-
-
- - - - {/* 차트 설정 */} -
- - -
-
- - - updateConfig({ - chart: { - ...config.chart, - enabled: v, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - }, - }) - } - /> -
- - {config.chart?.enabled && ( -
-
- - -
- -
- - - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - height: Number(e.target.value), - }, - }) - } - className="h-8 text-xs" />
- -
- + +
+ - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - showLegend: v, - }, - }) + updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, alternateRowColors: v } }) + } + /> +
+ +
+ + + updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) } />
- )} -
-
- - - - {/* 필드 선택기 설정 */} -
- - -
-
- - - updateConfig({ - fieldChooser: { ...config.fieldChooser, enabled: v }, - }) - } - />
-
- - - updateConfig({ - fieldChooser: { ...config.fieldChooser, allowSearch: v }, - }) - } - /> + {/* 크기 설정 */} +
+ +
+
+ + updateConfig({ height: e.target.value })} + placeholder="400px" + className="h-8 text-xs" + /> +
+
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
-
- - - - {/* 조건부 서식 설정 */} -
- - -
-
- - r.type === "colorScale" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "colorScale" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "colorScale-1", - type: "colorScale" as const, - colorScale: { - minColor: "#ff6b6b", - midColor: "#ffd93d", - maxColor: "#6bcb77", - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- -
- - r.type === "dataBar" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "dataBar" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "dataBar-1", - type: "dataBar" as const, - dataBar: { - color: "#3b82f6", - showValue: true, - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- -
- - r.type === "iconSet" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "iconSet" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "iconSet-1", - type: "iconSet" as const, - iconSet: { - type: "traffic", - thresholds: [33, 66], - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- - {config.style?.conditionalFormats && - config.style.conditionalFormats.length > 0 && ( -

- {config.style.conditionalFormats.length}개의 조건부 서식이 - 적용됨 -

- )} -
-
- - - - {/* 크기 설정 */} -
- - -
-
- - updateConfig({ height: e.target.value })} - placeholder="auto 또는 400px" - className="h-8 text-xs" - /> -
- -
- - updateConfig({ maxHeight: e.target.value })} - placeholder="600px" - className="h-8 text-xs" - /> -
-
-
+ )}
); }; export default PivotGridConfigPanel; - diff --git a/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx b/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx new file mode 100644 index 00000000..1dac623b --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/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, AggregationType } from "../types"; + +interface PivotContextMenuProps { + children: React.ReactNode; + // 현재 컨텍스트 정보 + cellType: "header" | "data" | "rowHeader" | "columnHeader"; + field?: PivotFieldConfig; + rowPath?: string[]; + columnPath?: string[]; + value?: any; + // 콜백 + onSort?: (field: string, direction: "asc" | "desc") => void; + onFilter?: (field: string) => void; + onExpand?: (path: string[]) => void; + onCollapse?: (path: string[]) => void; + onExpandAll?: () => void; + onCollapseAll?: () => void; + onCopy?: (value: any) => void; + onHideField?: (field: string) => void; + onChangeSummary?: (field: string, summaryType: AggregationType) => void; + onDrillDown?: (rowPath: string[], columnPath: string[]) => void; +} + +export const PivotContextMenu: React.FC = ({ + 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/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 063b4c6c..fed43afb 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -2,7 +2,7 @@ /** * FieldPanel 컴포넌트 - * 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터) + * 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터) * 드래그 앤 드롭으로 필드 재배치 가능 */ @@ -247,7 +247,7 @@ const DroppableArea: React.FC = ({ return (
= ({ data-area={area} > {/* 영역 헤더 */} -
+
{icon} {title} {areaFields.length > 0 && ( @@ -267,9 +267,9 @@ const DroppableArea: React.FC = ({ {/* 필드 목록 */} -
+
{areaFields.length === 0 ? ( - + 필드를 여기로 드래그 ) : ( @@ -443,16 +443,42 @@ export const FieldPanel: React.FC = ({ ? 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} + +
); @@ -466,9 +492,9 @@ export const FieldPanel: React.FC = ({ onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
- {/* 2x2 그리드로 영역 배치 */} -
+
+ {/* 4개 영역 배치: 2x2 그리드 */} +
{/* 필터 영역 */} = ({ {/* 접기 버튼 */} {onToggleCollapse && ( -
+
diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts index a901a7cf..9272e7db 100644 --- a/frontend/lib/registry/components/pivot-grid/components/index.ts +++ b/frontend/lib/registry/components/pivot-grid/components/index.ts @@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser"; export { DrillDownModal } from "./DrillDownModal"; export { FilterPopup } from "./FilterPopup"; export { PivotChart } from "./PivotChart"; +export { PivotContextMenu } from "./ContextMenu";
= ({ )} rowSpan={columnFields.length > 0 ? 2 : 1} > - {rowFields.map((f) => f.caption).join(" / ") || "항목"} +
+ {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 && 항목} +
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={ + + } + /> + ))} +
+
+