"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-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", }, column: { title: "열", icon: , color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800", }, row: { title: "행", icon: , color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-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;