"use client"; import React, { useState, useEffect, Suspense } 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, Settings, Info, Database, Trash2, Copy, Palette } from "lucide-react"; import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, TableInfo, LayoutComponent, FileComponent, AreaComponent, } from "@/types/screen"; import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans"; // 컬럼 스팬 숫자 배열 (1~12) // 동적으로 컬럼 수 배열 생성 (gridSettings.columns 기반) const generateColumnNumbers = (maxColumns: number) => { return Array.from({ length: maxColumns }, (_, i) => i + 1); }; import { cn } from "@/lib/utils"; import DataTableConfigPanel from "./DataTableConfigPanel"; import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { BaseInputType, BASE_INPUT_TYPE_OPTIONS, getBaseInputType, getDefaultDetailType, getDetailTypes, DetailTypeOption, } from "@/types/input-type-mapping"; import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent"; // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; import { Zap } from "lucide-react"; import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel"; import { ConditionalConfig } from "@/types/v2-components"; interface V2PropertiesPanelProps { selectedComponent?: ComponentData; tables: TableInfo[]; onUpdateProperty: (componentId: string, path: string, value: any) => void; onDeleteComponent?: (componentId: string) => void; onCopyComponent?: (componentId: string) => void; currentTable?: TableInfo; currentTableName?: string; dragState?: any; // 스타일 관련 onStyleChange?: (style: any) => void; // 🆕 플로우 위젯 감지용 allComponents?: ComponentData[]; // 🆕 메뉴 OBJID (코드/카테고리 스코프용) menuObjid?: number; // 🆕 현재 편집 중인 화면의 회사 코드 currentScreenCompanyCode?: string; } export const V2PropertiesPanel: React.FC = ({ selectedComponent, tables, onUpdateProperty, onDeleteComponent, onCopyComponent, currentTable, currentTableName, currentScreenCompanyCode, dragState, onStyleChange, menuObjid, allComponents = [], // 🆕 기본값 빈 배열 }) => { const { webTypes } = useWebTypes({ active: "Y" }); const [localComponentDetailType, setLocalComponentDetailType] = useState(""); // 높이/너비 입력 로컬 상태 (자유 입력 허용) const [localHeight, setLocalHeight] = useState(""); const [localWidth, setLocalWidth] = useState(""); // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) const [allTables, setAllTables] = useState>([]); // 🆕 전체 테이블 목록 로드 useEffect(() => { const loadAllTables = async () => { try { const { tableManagementApi } = await import("@/lib/api/tableManagement"); const response = await tableManagementApi.getTableList(); if (response.success && response.data) { setAllTables(response.data); } } catch (error) { console.error("전체 테이블 목록 로드 실패:", error); } }; loadAllTables(); }, []); // 새로운 컴포넌트 시스템의 webType 동기화 useEffect(() => { if (selectedComponent?.type === "component") { const webType = selectedComponent.componentConfig?.webType; if (webType) { setLocalComponentDetailType(webType); } } }, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]); // 높이 값 동기화 useEffect(() => { if (selectedComponent?.size?.height !== undefined) { setLocalHeight(String(selectedComponent.size.height)); } }, [selectedComponent?.size?.height, selectedComponent?.id]); // 너비 값 동기화 useEffect(() => { if (selectedComponent?.size?.width !== undefined) { setLocalWidth(String(selectedComponent.size.width)); } }, [selectedComponent?.size?.width, selectedComponent?.id]); // 컴포넌트가 선택되지 않았을 때는 안내 메시지만 표시 if (!selectedComponent) { return (
{/* 안내 메시지 */}

컴포넌트를 선택하여

속성을 편집하세요

); } const handleUpdate = (path: string, value: any) => { onUpdateProperty(selectedComponent.id, path, value); }; // 드래그 중일 때 실시간 위치 표시 const currentPosition = dragState?.isDragging && dragState?.draggedComponent?.id === selectedComponent.id ? dragState.currentPosition : selectedComponent.position; // 컴포넌트별 설정 패널 렌더링 함수 (DetailSettingsPanel의 로직) const renderComponentConfigPanel = () => { if (!selectedComponent) return null; // 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지 const componentType = selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등) selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id || selectedComponent.type; // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 const componentId = selectedComponent.componentType || // ⭐ section-card 등 selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id || (selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등) if (componentId) { const definition = ComponentRegistry.getComponent(componentId); if (definition?.configPanel) { const ConfigPanelComponent = definition.configPanel; const currentConfig = selectedComponent.componentConfig || {}; console.log("✅ ConfigPanel 표시:", { componentId, definitionName: definition.name, hasConfigPanel: !!definition.configPanel, currentConfig, defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인 }); // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) const config = currentConfig || definition.defaultProps?.componentConfig || {}; const handlePanelConfigChange = (newConfig: any) => { // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합 const mergedConfig = { ...currentConfig, // 기존 설정 유지 ...newConfig, // 새 설정 병합 }; console.log("🔧 [ConfigPanel] handleConfigChange:", { componentId: selectedComponent.id, currentConfig, newConfig, mergedConfig, }); onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig); }; return (
설정 패널 로딩 중...
} > ({ id: comp.id, componentType: comp.componentType || comp.type, label: comp.label || comp.name || comp.id, tableName: comp.componentConfig?.tableName || comp.tableName, columnName: comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName, }))} /> ); } // ConfigPanel이 없으면 DynamicComponentConfigPanel fallback으로 처리 } // DynamicComponentConfigPanel을 통한 동적 로드 (CONFIG_PANEL_MAP 기반) const fallbackId = componentId || componentType; if (fallbackId && hasComponentConfigPanel(fallbackId)) { const handleDynamicConfigChange = (newConfig: any) => { const currentConfig = selectedComponent.componentConfig || {}; const mergedConfig = { ...currentConfig, ...newConfig }; onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig); }; return ( ); } return (

설정 패널 없음

컴포넌트 "{fallbackId || componentType}"에 대한 설정 패널이 없습니다.

); }; // 기본 정보 탭 const renderBasicTab = () => { const widget = selectedComponent as WidgetComponent; const group = selectedComponent as GroupComponent; const area = selectedComponent as AreaComponent; // 라벨 설정이 표시될 입력 필드 타입들 const inputFieldTypes = [ "text", "number", "decimal", "date", "datetime", "time", "email", "tel", "url", "password", "textarea", "select", "dropdown", "entity", "code", "checkbox", "radio", "boolean", "file", "autocomplete", "text-input", "number-input", "date-input", "textarea-basic", "select-basic", "checkbox-basic", "radio-basic", "entity-search-input", "autocomplete-search-input", // 새로운 통합 입력 컴포넌트 "v2-input", "v2-select", "v2-entity-select", "v2-checkbox", "v2-radio", "v2-textarea", "v2-date", "v2-datetime", "v2-time", "v2-file", ]; // 현재 컴포넌트가 입력 필드인지 확인 const componentType = widget.widgetType || (widget as any).componentId || (widget as any).componentType; const isInputField = inputFieldTypes.includes(componentType); return (
{/* 너비 + 높이 (같은 행) */}
{ setLocalWidth(e.target.value); }} onBlur={(e) => { const value = parseInt(e.target.value) || 0; if (value >= 10) { const snappedValue = Math.round(value / 10) * 10; handleUpdate("size.width", snappedValue); setLocalWidth(String(snappedValue)); } }} onKeyDown={(e) => { if (e.key === "Enter") { const value = parseInt(e.currentTarget.value) || 0; if (value >= 10) { const snappedValue = Math.round(value / 10) * 10; handleUpdate("size.width", snappedValue); setLocalWidth(String(snappedValue)); } e.currentTarget.blur(); } }} placeholder="100" className="h-6 w-full px-2 py-0 text-xs" />
{ setLocalHeight(e.target.value); }} onBlur={(e) => { const value = parseInt(e.target.value) || 0; if (value >= 10) { const snappedValue = Math.round(value / 10) * 10; handleUpdate("size.height", snappedValue); setLocalHeight(String(snappedValue)); } }} onKeyDown={(e) => { if (e.key === "Enter") { const value = parseInt(e.currentTarget.value) || 0; if (value >= 10) { const snappedValue = Math.round(value / 10) * 10; handleUpdate("size.height", snappedValue); setLocalHeight(String(snappedValue)); } e.currentTarget.blur(); } }} step={1} placeholder="10" className="h-6 w-full px-2 py-0 text-xs" />
{/* Title (group/area) */} {(selectedComponent.type === "group" || selectedComponent.type === "area") && (
handleUpdate("title", e.target.value)} placeholder="제목" className="h-6 w-full px-2 py-0 text-xs" />
)} {/* Description (area만) */} {selectedComponent.type === "area" && (
handleUpdate("description", e.target.value)} placeholder="설명" className="h-6 w-full px-2 py-0 text-xs" />
)} {/* Z-Index */}
handleUpdate("position.z", parseInt(e.target.value) || 1)} className="h-6 w-full px-2 py-0 text-xs" />
{/* 라벨 스타일 - 입력 필드에서만 표시 */} {isInputField && ( 라벨 스타일
{ handleUpdate("style.labelText", e.target.value); handleUpdate("label", e.target.value); // label도 함께 업데이트 }} placeholder="라벨을 입력하세요 (비우면 라벨 없음)" className="h-6 w-full px-2 py-0 text-xs" />
{ const pos = selectedComponent.style?.labelPosition; if (pos === "left" || pos === "right") { handleUpdate("style.labelGap", e.target.value); } else { handleUpdate("style.labelMarginBottom", e.target.value); } }} className="h-6 w-full px-2 py-0 text-xs" />
handleUpdate("style.labelFontSize", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" />
handleUpdate("style.labelColor", value)} defaultColor="#212121" placeholder="#212121" />
{ const boolValue = checked === true; // 🔧 "필수"처럼 직접 경로로 업데이트! (style 객체 전체 덮어쓰기 방지) handleUpdate("style.labelDisplay", boolValue); handleUpdate("labelDisplay", boolValue); // labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음) if (boolValue && !selectedComponent.style?.labelText) { const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || ""; if (labelValue) { handleUpdate("style.labelText", labelValue); } } }} className="h-4 w-4" />
)} {/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
{(isInputField || widget.required !== undefined) && (() => { const colName = widget.columnName || selectedComponent?.columnName; const colMeta = colName ? currentTable?.columns?.find( (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase() ) : null; const isNotNull = colMeta && ((colMeta as any).isNullable === "NO" || (colMeta as any).isNullable === "N" || (colMeta as any).is_nullable === "NO" || (colMeta as any).is_nullable === "N"); return (
{ if (isNotNull) return; handleUpdate("required", checked); handleUpdate("componentConfig.required", checked); }} disabled={!!isNotNull} className="h-4 w-4" />
); })()} {(isInputField || widget.readonly !== undefined) && (
{ handleUpdate("readonly", checked); handleUpdate("componentConfig.readonly", checked); }} className="h-4 w-4" />
)} {/* 숨김 옵션 - 모든 컴포넌트에서 표시 */}
{ handleUpdate("hidden", checked); handleUpdate("componentConfig.hidden", checked); }} className="h-4 w-4" />
); }; // 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합) const renderDetailTab = () => { // 1. DataTable 컴포넌트 if (selectedComponent.type === "datatable") { return ( { Object.entries(updates).forEach(([key, value]) => { handleUpdate(key, value); }); }} /> ); } // 3. 파일 컴포넌트 if (isFileComponent(selectedComponent)) { return ( ); } // 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리 const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || ""; if (v2ComponentType.startsWith("v2-")) { const configPanel = renderComponentConfigPanel(); if (configPanel) { return
{configPanel}
; } } // 4. 새로운 컴포넌트 시스템 (button, card 등) const componentType = selectedComponent.componentConfig?.type || selectedComponent.type; const hasNewConfigPanel = componentType && [ "button", "button-primary", "button-secondary", "v2-button-primary", "card", "dashboard", "stats", "stats-card", "progress", "progress-bar", "chart", "chart-basic", "alert", "alert-info", "badge", "badge-status", ].includes(componentType); if (hasNewConfigPanel) { const configPanel = renderComponentConfigPanel(); if (configPanel) { return
{configPanel}
; } } // 5. 새로운 컴포넌트 시스템 (type: "component") if (selectedComponent.type === "component") { const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type; const webType = selectedComponent.componentConfig?.webType; // 테이블 패널에서 드래그한 컴포넌트인지 확인 const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName); if (!componentId) { return (

컴포넌트 ID가 설정되지 않았습니다

); } // 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인 const definition = ComponentRegistry.getComponent(componentId); if (definition?.configPanel) { // 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출 const configPanelContent = renderComponentConfigPanel(); if (configPanelContent) { return configPanelContent; } } // 현재 웹타입의 기본 입력 타입 추출 const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; // 선택 가능한 세부 타입 목록 const availableDetailTypes = currentBaseInputType ? getDetailTypes(currentBaseInputType) : []; // 세부 타입 변경 핸들러 const handleDetailTypeChange = (newDetailType: string) => { setLocalComponentDetailType(newDetailType); handleUpdate("componentConfig.webType", newDetailType); }; return (
{/* 세부 타입 선택 - 테이블 패널에서 드래그한 컴포넌트만 표시 */} {isFromTablePanel && webType && availableDetailTypes.length > 1 && (
)} {/* DynamicComponentConfigPanel */} { console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", { componentId: selectedComponent.id, newConfigKeys: Object.keys(newConfig), defaultSort: newConfig.defaultSort, newConfig, }); // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 Object.entries(newConfig).forEach(([key, value]) => { console.log(` -> handleUpdate: componentConfig.${key} =`, value); handleUpdate(`componentConfig.${key}`, value); }); }} /> {/* 🆕 테이블 데이터 자동 입력 (component 타입용) */}

테이블 데이터 자동 입력

{/* 활성화 체크박스 */}
{ handleUpdate("autoFill", { ...selectedComponent.autoFill, enabled: Boolean(checked), }); }} />
{selectedComponent.autoFill?.enabled && ( <> {/* 조회할 테이블 */}
{/* 필터링할 컬럼 */}
{ handleUpdate("autoFill", { ...selectedComponent.autoFill, enabled: selectedComponent.autoFill?.enabled || false, filterColumn: e.target.value, }); }} placeholder="예: company_code" className="h-6 w-full px-2 py-0 text-xs" />
{/* 사용자 정보 필드 */}
{/* 표시할 컬럼 */}
{ handleUpdate("autoFill", { ...selectedComponent.autoFill, enabled: selectedComponent.autoFill?.enabled || false, displayColumn: e.target.value, }); }} placeholder="예: company_name" className="h-6 w-full px-2 py-0 text-xs" />
)}
); } // 6. Widget 컴포넌트 if (selectedComponent.type === "widget") { console.log("✅ [renderDetailTab] Widget 타입"); const widget = selectedComponent as WidgetComponent; console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType); // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크 if ( widget.widgetType && ["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes( widget.widgetType, ) ) { console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)"); return ( { console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig); // 전체 componentConfig를 업데이트 handleUpdate("componentConfig", newConfig); }} /> ); } // 일반 위젯 (webType 기반) console.log("✅ [renderDetailTab] 일반 위젯 렌더링 시작"); return (
{console.log("🔍 [V2PropertiesPanel] widget.webType:", widget.webType, "widget:", widget)} {/* WebType 선택 (있는 경우만) */} {widget.webType && (
)} {/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */}

테이블 데이터 자동 입력

{/* 활성화 체크박스 */}
{ handleUpdate("autoFill", { ...widget.autoFill, enabled: Boolean(checked), }); }} />
{widget.autoFill?.enabled && ( <> {/* 조회할 테이블 */}
{/* 필터링할 컬럼 */}
{ handleUpdate("autoFill", { ...widget.autoFill, enabled: widget.autoFill?.enabled || false, filterColumn: e.target.value, }); }} placeholder="예: company_code" className="h-6 w-full px-2 py-0 text-xs" />
{/* 사용자 정보 필드 */}
{/* 표시할 컬럼 */}
{ handleUpdate("autoFill", { ...widget.autoFill, enabled: widget.autoFill?.enabled || false, displayColumn: e.target.value, }); }} placeholder="예: company_name" className="h-6 w-full px-2 py-0 text-xs" />
)}
); } // 기본 메시지 return (

이 컴포넌트는 추가 설정이 없습니다

); }; return (
{/* 헤더 - 간소화 */}
{selectedComponent.type === "widget" && (
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
)}
{/* 통합 컨텐츠 (탭 제거) */}
{/* 기본 설정 */} {renderBasicTab()} {/* 상세 설정 */} {renderDetailTab()} {/* 조건부 표시 설정 */} {selectedComponent && ( <>

조건부 표시

{ handleUpdate("conditional", newConfig); }} availableFields={ allComponents ?.filter((c) => { // 자기 자신 제외 if (c.id === selectedComponent.id) return false; // widget 타입 또는 component 타입 (V2 컴포넌트 포함) return c.type === "widget" || c.type === "component"; }) .map((c) => { const widgetType = (c as any).widgetType || (c as any).componentType || "text"; const config = (c as any).componentConfig || (c as any).webTypeConfig || {}; const detailSettings = (c as any).detailSettings || {}; // 정적 옵션 추출 (select, dropdown, radio, entity 등) let options: Array<{ value: string; label: string }> | undefined; // V2 컴포넌트의 경우 if (config.options && Array.isArray(config.options)) { options = config.options; } // 레거시 컴포넌트의 경우 else if ((c as any).options && Array.isArray((c as any).options)) { options = (c as any).options; } // 엔티티 정보 추출 (config > detailSettings > 직접 속성 순으로 우선순위) const entityTable = config.entityTable || detailSettings.referenceTable || (c as any).entityTable || (c as any).referenceTable; const entityValueColumn = config.entityValueColumn || detailSettings.referenceColumn || (c as any).entityValueColumn || (c as any).referenceColumn; const entityLabelColumn = config.entityLabelColumn || detailSettings.displayColumn || (c as any).entityLabelColumn || (c as any).displayColumn; // 공통코드 정보 추출 const codeGroup = config.codeGroup || detailSettings.codeGroup || (c as any).codeGroup; return { id: (c as any).columnName || c.id, label: (c as any).label || config.label || c.id, type: widgetType, options, entityTable, entityValueColumn, entityLabelColumn, codeGroup, }; }) || [] } currentComponentId={selectedComponent.id} />
)} {/* 스타일 설정 */} {selectedComponent && ( <>

컴포넌트 스타일

{ if (onStyleChange) { onStyleChange(style); } else { handleUpdate("style", style); } }} />
)}
); }; export default V2PropertiesPanel;