"use client"; import React, { useState, useEffect, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ChevronDown } from "lucide-react"; import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react"; import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, AreaComponent, AreaLayoutType, TableInfo, } from "@/types/screen"; import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans"; import { cn } from "@/lib/utils"; import DataTableConfigPanel from "./DataTableConfigPanel"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; // DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트 const DataTableConfigPanelWrapper: React.FC<{ selectedComponent: DataTableComponent; tables: TableInfo[]; activeTab: string; onTabChange: (tab: string) => void; onUpdateProperty: (property: string, value: any) => void; }> = React.memo( ({ selectedComponent, tables, activeTab, onTabChange, onUpdateProperty }) => { // 안정화된 업데이트 핸들러 const handleUpdateComponent = React.useCallback( (updates: Partial) => { // console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates); // 변경사항이 있는지 확인 (간단한 비교로 성능 향상) const hasChanges = Object.entries(updates).some(([key, value]) => { const currentValue = (selectedComponent as any)[key]; // 배열의 경우 길이만 비교 if (Array.isArray(currentValue) && Array.isArray(value)) { return currentValue.length !== value.length; } // 기본값 비교 return currentValue !== value; }); if (!hasChanges) { // console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵"); return; } // 각 속성을 개별적으로 업데이트 Object.entries(updates).forEach(([key, value]) => { onUpdateProperty(key, value); }); }, [selectedComponent.id, onUpdateProperty], ); // ID만 의존성으로 사용 return ( ); }, (prevProps, nextProps) => { // 컴포넌트 ID가 다르면 리렌더링 if (prevProps.selectedComponent.id !== nextProps.selectedComponent.id) { return false; } // 테이블 목록이 변경되면 리렌더링 if (prevProps.tables.length !== nextProps.tables.length) { return false; } // 활성 탭이 변경되면 리렌더링 if (prevProps.activeTab !== nextProps.activeTab) { return false; } // 그 외의 경우는 리렌더링하지 않음 return true; }, ); interface PropertiesPanelProps { selectedComponent?: ComponentData; tables?: TableInfo[]; dragState?: { isDragging: boolean; draggedComponent: ComponentData | null; currentPosition: { x: number; y: number; z: number }; }; onUpdateProperty: (path: string, value: unknown) => void; onDeleteComponent: () => void; onCopyComponent: () => void; onGroupComponents?: () => void; onUngroupComponents?: () => void; canGroup?: boolean; canUngroup?: boolean; } // 동적 웹타입 옵션은 컴포넌트 내부에서 useWebTypes 훅으로 가져옵니다 const PropertiesPanelComponent: React.FC = ({ selectedComponent, tables = [], dragState, onUpdateProperty, onDeleteComponent, onCopyComponent, onGroupComponents, onUngroupComponents, canGroup = false, canUngroup = false, }) => { // 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인 // console.log("📍 PropertiesPanel 렌더링:", { // renderTime: Date.now(), // selectedComponentId: selectedComponent?.id, // dragState: dragState // ? { // isDragging: dragState.isDragging, // draggedComponentId: dragState.draggedComponent?.id, // currentPosition: dragState.currentPosition, // dragStateRef: dragState, // 객체 참조 확인 // } // : "null", // }); // 동적 웹타입 목록 가져오기 - API에서 직접 조회 const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" }); // 강제 리렌더링을 위한 state (드래그 중 실시간 업데이트용) const [forceRender, setForceRender] = useState(0); // 드래그 상태를 직접 추적하여 리렌더링 강제 const [lastDragPosition, setLastDragPosition] = useState({ x: 0, y: 0 }); // 웹타입 옵션 생성 - 데이터베이스 기반 const webTypeOptions = webTypes.map((webType) => ({ value: webType.web_type as WebType, label: webType.type_name, })); // 데이터테이블 설정 탭 상태를 여기서 관리 const [dataTableActiveTab, setDataTableActiveTab] = useState("basic"); // 최신 값들의 참조를 유지 const selectedComponentRef = useRef(selectedComponent); const onUpdatePropertyRef = useRef(onUpdateProperty); // 실시간 위치 계산 (드래그 중일 때는 dragState.currentPosition 사용) const getCurrentPosition = () => { if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) { // console.log("🎯 드래그 중 실시간 위치:", { // draggedId: dragState.draggedComponent?.id, // selectedId: selectedComponent?.id, // currentPosition: dragState.currentPosition, // }); return { x: Math.round(dragState.currentPosition.x), y: Math.round(dragState.currentPosition.y), }; } return { x: selectedComponent?.position?.x || 0, y: selectedComponent?.position?.y || 0, }; }; const currentPosition = getCurrentPosition(); // 입력 필드들의 로컬 상태 (실시간 타이핑 반영용) const [localInputs, setLocalInputs] = useState({ placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "", title: (selectedComponent?.type === "group" ? (selectedComponent as GroupComponent).title : selectedComponent?.type === "area" ? (selectedComponent as AreaComponent).title : "") || "", description: (selectedComponent?.type === "area" ? (selectedComponent as AreaComponent).description : "") || "", positionX: currentPosition.x.toString(), positionY: currentPosition.y.toString(), positionZ: selectedComponent?.position.z?.toString() || "1", width: selectedComponent?.size?.width?.toString() || "0", height: selectedComponent?.size?.height?.toString() || "0", gridColumns: selectedComponent?.gridColumns?.toString() || (selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout" ? "8" : selectedComponent?.type === "component" && (selectedComponent as any)?.componentConfig?.type === "card-display" ? "8" : "1"), labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", labelFontSize: selectedComponent?.style?.labelFontSize || "12px", labelColor: selectedComponent?.style?.labelColor || "#212121", labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px", required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false, readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false, labelDisplay: selectedComponent?.style?.labelDisplay ?? true, // widgetType도 로컬 상태로 관리 widgetType: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text", }); useEffect(() => { selectedComponentRef.current = selectedComponent; onUpdatePropertyRef.current = onUpdateProperty; }); // 선택된 컴포넌트가 변경될 때 로컬 입력 상태 업데이트 useEffect(() => { if (selectedComponent) { const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null; const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null; const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null; // console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", { // componentId: selectedComponent.id, // componentType: selectedComponent.type, // isDragging: dragState?.isDragging, // justFinishedDrag: dragState?.justFinishedDrag, // currentValues: { // placeholder: widget?.placeholder, // title: group?.title || area?.title, // description: area?.description, // actualPositionX: selectedComponent.position.x, // actualPositionY: selectedComponent.position.y, // dragPositionX: dragState?.currentPosition.x, // dragPositionY: dragState?.currentPosition.y, // }, // getCurrentPosResult: getCurrentPosition(), // }); // 드래그 중이 아닐 때만 localInputs 업데이트 (드래그 완료 후 최종 위치 반영) if (!dragState?.isDragging || dragState.draggedComponent?.id !== selectedComponent.id) { const currentPos = getCurrentPosition(); setLocalInputs({ placeholder: widget?.placeholder || "", title: group?.title || area?.title || "", description: area?.description || "", positionX: currentPos.x.toString(), positionY: currentPos.y.toString(), positionZ: selectedComponent?.position?.z?.toString() || "1", width: selectedComponent?.size?.width?.toString() || "0", // 안전한 접근 height: selectedComponent?.size?.height?.toString() || "0", // 안전한 접근 gridColumns: selectedComponent?.gridColumns?.toString() || (selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout" ? "8" : "1"), labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", labelFontSize: selectedComponent?.style?.labelFontSize || "12px", labelColor: selectedComponent?.style?.labelColor || "#212121", labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px", required: widget?.required || false, readonly: widget?.readonly || false, labelDisplay: selectedComponent.style?.labelDisplay ?? true, // widgetType 동기화 widgetType: widget?.widgetType || "text", }); // console.log("✅ localInputs 업데이트 완료:", { // positionX: currentPos.x.toString(), // positionY: currentPos.y.toString(), // }); } } }, [ selectedComponent?.id, // ID만 감지하여 컴포넌트 변경 시에만 업데이트 selectedComponent?.position.x, // 컴포넌트 실제 위치 변경 감지 (드래그 완료 후) selectedComponent?.position.y, selectedComponent?.position.z, // z 위치도 감지 dragState?.isDragging, // 드래그 상태 변경 감지 (드래그 완료 감지용) dragState?.justFinishedDrag, // 드래그 완료 직후 감지 ]); // 🔴 삭제 액션일 때 라벨 색상 자동 설정 useEffect(() => { if (selectedComponent && selectedComponent.type === "component") { // 삭제 액션 감지 로직 (실제 필드명 사용) const isDeleteAction = () => { const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"]; return ( selectedComponent.componentConfig?.action?.type === "delete" || selectedComponent.config?.action?.type === "delete" || selectedComponent.webTypeConfig?.actionType === "delete" || selectedComponent.text?.toLowerCase().includes("삭제") || selectedComponent.text?.toLowerCase().includes("delete") || selectedComponent.label?.toLowerCase().includes("삭제") || selectedComponent.label?.toLowerCase().includes("delete") || deleteKeywords.some( (keyword) => selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) || selectedComponent.config?.text?.toLowerCase().includes(keyword), ) ); }; // 🔍 디버깅: 컴포넌트 구조 확인 // console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", { // componentType: selectedComponent.type, // componentId: selectedComponent.id, // componentConfig: selectedComponent.componentConfig, // config: selectedComponent.config, // webTypeConfig: selectedComponent.webTypeConfig, // actionType1: selectedComponent.componentConfig?.action?.type, // actionType2: selectedComponent.config?.action?.type, // actionType3: selectedComponent.webTypeConfig?.actionType, // isDeleteAction: isDeleteAction(), // currentLabelColor: selectedComponent.style?.labelColor, // }); // 액션에 따른 라벨 색상 자동 설정 if (isDeleteAction()) { // 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만) if (selectedComponent.style?.labelColor !== "#ef4444") { // console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정"); onUpdateProperty("style", { ...selectedComponent.style, labelColor: "#ef4444", }); // 로컬 입력 상태도 업데이트 setLocalInputs((prev) => ({ ...prev, labelColor: "#ef4444", })); } } else { // 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만) if (selectedComponent.style?.labelColor === "#ef4444") { // console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋"); onUpdateProperty("style", { ...selectedComponent.style, labelColor: "#212121", }); // 로컬 입력 상태도 업데이트 setLocalInputs((prev) => ({ ...prev, labelColor: "#212121", })); } } } }, [ selectedComponent?.componentConfig?.action?.type, selectedComponent?.config?.action?.type, selectedComponent?.webTypeConfig?.actionType, selectedComponent?.id, selectedComponent?.style?.labelColor, // 라벨 색상 변경도 감지 JSON.stringify(selectedComponent?.componentConfig), // 전체 componentConfig 변경 감지 onUpdateProperty, ]); // 렌더링 시마다 실행되는 직접적인 드래그 상태 체크 if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) { // console.log("🎯 렌더링 중 드래그 상태 감지:", { // isDragging: dragState.isDragging, // draggedId: dragState.draggedComponent?.id, // selectedId: selectedComponent?.id, // currentPosition: dragState.currentPosition, // }); const newPosition = { x: dragState.currentPosition.x, y: dragState.currentPosition.y, }; // 위치가 변경되었는지 확인 if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) { // console.log("🔄 위치 변경 감지됨:", { // oldPosition: lastDragPosition, // newPosition: newPosition, // }); // 다음 렌더링 사이클에서 업데이트 setTimeout(() => { setLastDragPosition(newPosition); setForceRender((prev) => prev + 1); }, 0); } } if (!selectedComponent) { return (

컴포넌트를 선택하세요

캔버스에서 컴포넌트를 클릭하면 속성을 편집할 수 있습니다.

); } // 데이터 테이블 컴포넌트인 경우 전용 패널 사용 if (selectedComponent.type === "datatable") { return (
{/* 헤더 */}
데이터 테이블 설정
{selectedComponent.type}
{/* 액션 버튼들 */}
{/* 데이터 테이블 설정 패널 */}
); } return (
{/* 헤더 */}

속성 편집

{selectedComponent.type}
{/* 액션 버튼들 */}
{canGroup && ( )} {canUngroup && ( )}
{/* 속성 편집 영역 */}
{/* 기본 정보 */}

기본 정보

{selectedComponent.type === "widget" && ( <>
{ const newValue = e.target.value; // console.log("🔄 placeholder 변경:", newValue); setLocalInputs((prev) => ({ ...prev, placeholder: newValue })); onUpdateProperty("placeholder", newValue); }} placeholder="입력 힌트 텍스트" className="mt-1" />
{ setLocalInputs((prev) => ({ ...prev, required: e.target.checked })); onUpdateProperty("required", e.target.checked); }} className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2" />
{ setLocalInputs((prev) => ({ ...prev, readonly: e.target.checked })); onUpdateProperty("readonly", e.target.checked); }} className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2" />
)}
{/* 위치 및 크기 */}

위치 및 크기

{ const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id; if (isDragging) { const realTimeX = Math.round(dragState.currentPosition.x); // console.log("🔥 실시간 X 렌더링:", realTimeX, "forceRender:", forceRender); return realTimeX.toString(); } return localInputs.positionX; })()} onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, positionX: newValue })); onUpdateProperty("position.x", Number(newValue)); }} className={`mt-1 ${ dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id ? "bg-accent border-blue-300 text-blue-700" : "" }`} readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id} />
{ const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id; if (isDragging) { const realTimeY = Math.round(dragState.currentPosition.y); // console.log("🔥 실시간 Y 렌더링:", realTimeY, "forceRender:", forceRender); return realTimeY.toString(); } return localInputs.positionY; })()} onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, positionY: newValue })); onUpdateProperty("position.y", Number(newValue)); }} className={`mt-1 ${ dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id ? "bg-accent border-blue-300 text-blue-700" : "" }`} readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id} />
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */} {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? ( <> {/* 🆕 컬럼 스팬 선택 (width 대체) */}
{/* 시각적 프리뷰 */}
{Array.from({ length: 12 }).map((_, i) => { const spanValue = COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]; const startCol = selectedComponent.gridColumnStart || 1; const isActive = i + 1 >= startCol && i + 1 < startCol + spanValue; return (
); })}

{COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12 컬럼

{/* 고급 설정 */}

"자동"을 선택하면 이전 컴포넌트 다음에 배치됩니다

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, height: newValue })); onUpdateProperty("size.height", Number(newValue)); }} className="mt-1" />
) : (

카드 레이아웃은 자동으로 크기가 계산됩니다

카드 개수와 간격 설정은 상세설정에서 조정하세요

)}
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, positionZ: newValue })); onUpdateProperty("position.z", Number(newValue)); }} className="mt-1" placeholder="1" />
{ const newValue = e.target.value; const numValue = Number(newValue); if (numValue >= 1 && numValue <= 12) { setLocalInputs((prev) => ({ ...prev, gridColumns: newValue })); onUpdateProperty("gridColumns", numValue); } }} placeholder="1" className="mt-1" />
이 컴포넌트가 차지할 그리드 컬럼 수를 설정합니다 (기본: 1)
{/* 라벨 스타일 */}

라벨 설정

{/* 라벨 표시 토글 */}
{ // console.log("🔄 라벨 표시 변경:", e.target.checked); setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked })); onUpdateProperty("style.labelDisplay", e.target.checked); }} className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2" />
{/* 라벨 텍스트 */}
{ const newValue = e.target.value; // console.log("🔄 라벨 텍스트 변경:", newValue); setLocalInputs((prev) => ({ ...prev, labelText: newValue })); // 기본 라벨과 스타일 라벨을 모두 업데이트 onUpdateProperty("label", newValue); onUpdateProperty("style.labelText", newValue); }} placeholder="라벨 텍스트" className="mt-1" />
{/* 라벨 스타일 */}
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, labelFontSize: newValue })); onUpdateProperty("style.labelFontSize", newValue); }} placeholder="12px" className="mt-1" />
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, labelColor: newValue })); onUpdateProperty("style.labelColor", newValue); }} className="mt-1 h-8" />
{/* 라벨 여백 */}
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, labelMarginBottom: newValue })); onUpdateProperty("style.labelMarginBottom", newValue); }} placeholder="4px" className="mt-1" />
{selectedComponent.type === "group" && ( <> {/* 그룹 설정 */}

그룹 설정

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, title: newValue })); onUpdateProperty("title", newValue); }} placeholder="그룹 제목" className="mt-1" />
)} {selectedComponent.type === "area" && ( <> {/* 영역 설정 */}

영역 설정

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, title: newValue })); onUpdateProperty("title", newValue); }} placeholder="영역 제목" className="mt-1" />
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, description: newValue })); onUpdateProperty("description", newValue); }} placeholder="영역 설명 (선택사항)" className="mt-1" />
{/* 레이아웃별 상세 설정 */} {(selectedComponent as AreaComponent).layoutType === "grid" && (
그리드 설정
{ const value = Number(e.target.value); onUpdateProperty("layoutConfig.gridColumns", value); }} className="mt-1" />
{ const value = Number(e.target.value); onUpdateProperty("layoutConfig.gridGap", value); }} className="mt-1" />
)} {((selectedComponent as AreaComponent).layoutType === "flex-row" || (selectedComponent as AreaComponent).layoutType === "flex-column") && (
플렉스 설정
{ const value = Number(e.target.value); onUpdateProperty("layoutConfig.gap", value); }} className="mt-1" />
)} {(selectedComponent as AreaComponent).layoutType === "sidebar" && (
사이드바 설정
{ const value = Number(e.target.value); onUpdateProperty("layoutConfig.sidebarWidth", value); }} className="mt-1" />
)}
)}
); }; // React.memo로 감싸서 불필요한 리렌더링 방지 export const PropertiesPanel = React.memo(PropertiesPanelComponent, (prevProps, nextProps) => { // 선택된 컴포넌트 ID가 다르면 리렌더링 if (prevProps.selectedComponent?.id !== nextProps.selectedComponent?.id) { return false; } // 선택된 컴포넌트가 없는 상태에서 있는 상태로 변경되거나 그 반대인 경우 if (!prevProps.selectedComponent !== !nextProps.selectedComponent) { return false; } // 테이블 목록이 변경되면 리렌더링 if (prevProps.tables.length !== nextProps.tables.length) { return false; } // 그룹 관련 props가 변경되면 리렌더링 if (prevProps.canGroup !== nextProps.canGroup || prevProps.canUngroup !== nextProps.canUngroup) { return false; } // 그 외의 경우는 리렌더링하지 않음 return true; }); export default PropertiesPanel;