diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index 9179afbe..8d7e6dc0 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { @@ -78,6 +79,11 @@ export const LayerManagerPanel: React.FC = ({ // 기본 레이어 컴포넌트 로드 const loadBaseLayerComponents = useCallback(async () => { if (!screenId) return; + // 현재 활성 레이어가 기본 레이어(1)이면 props의 실시간 컴포넌트 사용 + if (activeLayerId === 1 && components.length > 0) { + setBaseLayerComponents(components); + return; + } try { const data = await screenApi.getLayerLayout(screenId, 1); if (data?.components) { @@ -86,7 +92,7 @@ export const LayerManagerPanel: React.FC = ({ } catch { setBaseLayerComponents(components); } - }, [screenId, components]); + }, [screenId, components, activeLayerId]); useEffect(() => { loadLayers(); @@ -191,6 +197,22 @@ export const LayerManagerPanel: React.FC = ({ ["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t)) ); + // Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 + const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => { + if (!zone.trigger_component_id) return []; + const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id); + if (!triggerComp) return []; + + const config = triggerComp.componentConfig || {}; + // 정적 옵션 (v2-select static source) + if (config.options && Array.isArray(config.options)) { + return config.options + .filter((opt: any) => opt.value) + .map((opt: any) => ({ value: opt.value, label: opt.label || opt.value })); + } + return []; + }, [baseLayerComponents]); + return (
{/* 헤더 */} @@ -335,6 +357,8 @@ export const LayerManagerPanel: React.FC = ({ {/* Zone 소속 레이어 목록 */} {zoneLayers.map((layer) => { const isActive = activeLayerId === layer.layer_id; + const triggerOpts = getTriggerOptions(zone); + const currentCondValue = layer.condition_config?.condition_value || ""; return (
= ({
{layer.layer_name} -
- - 조건값: {layer.condition_config?.condition_value || "미설정"} - +
+ {triggerOpts.length > 0 ? ( + + ) : ( + + 조건값: {currentCondValue || "미설정"} + + )} | {layer.component_count}개 @@ -373,14 +434,41 @@ export const LayerManagerPanel: React.FC = ({ {/* 레이어 추가 */} {addingToZoneId === zone.zone_id ? (
- setNewConditionValue(e.target.value)} - placeholder="조건값 입력 (예: 옵션1)" - className="h-6 text-[11px] flex-1" - autoFocus - onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} - /> + {(() => { + const triggerOpts = getTriggerOptions(zone); + // 이미 사용된 조건값 제외 + const usedValues = new Set( + zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean) + ); + const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value)); + + if (availableOpts.length > 0) { + return ( + + ); + } + return ( + setNewConditionValue(e.target.value)} + placeholder="조건값 입력" + className="h-6 text-[11px] flex-1" + autoFocus + onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} + /> + ); + })()} -
-
- - - - 컴포넌트 - - - 레이어 - - - 편집 - - - - - { - const dragData = { - type: column ? "column" : "table", - table, - column, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - selectedTableName={selectedScreen?.tableName} - placedColumns={placedColumns} - onTableSelect={handleTableSelect} - showTableSelector={true} - /> - - - {/* 🆕 레이어 관리 탭 (DB 기반) */} - - { - if (!selectedScreen?.screenId) return; - try { - // 1. 현재 레이어 저장 - const curId = Number(activeLayerIdRef.current) || 1; - const v2Layout = convertLegacyToV2({ ...layout, screenResolution }); - await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId }); - - // 2. 새 레이어 로드 - const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId); - if (data && data.components) { - const legacy = convertV2ToLegacy(data); - if (legacy) { - setLayout((prev) => ({ ...prev, components: legacy.components })); - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - - setActiveLayerIdWithRef(layerId); - setSelectedComponent(null); - } catch (error) { - console.error("레이어 전환 실패:", error); - toast.error("레이어 전환에 실패했습니다."); - } - }} - components={layout.components} - zones={zones} - onZonesChange={setZones} - /> - - - - {/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */} - {selectedTabComponentInfo ? ( - (() => { - const tabComp = selectedTabComponentInfo.component; - - // 탭 내부 컴포넌트를 ComponentData 형식으로 변환 - const tabComponentAsComponentData: ComponentData = { - id: tabComp.id, - type: "component", - componentType: tabComp.componentType, - label: tabComp.label, - position: tabComp.position || { x: 0, y: 0 }, - size: tabComp.size || { width: 200, height: 100 }, - componentConfig: tabComp.componentConfig || {}, - style: tabComp.style || {}, - } as ComponentData; - - // 탭 내부 컴포넌트용 속성 업데이트 핸들러 (중첩 구조 지원) - const updateTabComponentProperty = (componentId: string, path: string, value: any) => { - const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = - selectedTabComponentInfo; - - console.log("🔧 updateTabComponentProperty 호출:", { - componentId, - path, - value, - parentSplitPanelId, - parentPanelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 - const setNestedValue = (obj: any, pathStr: string, val: any): any => { - // 깊은 복사로 시작 - const result = JSON.parse(JSON.stringify(obj)); - const parts = pathStr.split("."); - let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = val; - return result; - }; - - // 탭 컴포넌트 업데이트 함수 - const updateTabsComponent = (tabsComponent: any) => { - const currentConfig = JSON.parse(JSON.stringify(tabsComponent.componentConfig || {})); - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).map((comp: any) => { - if (comp.id !== componentId) return comp; - - // 🆕 안전한 깊은 경로 업데이트 사용 - const updatedComp = setNestedValue(comp, path, value); - console.log("🔧 컴포넌트 업데이트 결과:", updatedComp); - return updatedComp; - }), - }; - } - return tab; - }); - - return { - ...tabsComponent, - componentConfig: { ...currentConfig, tabs: updatedTabs }, - }; - }; - - setLayout((prevLayout) => { - let newLayout; - let updatedTabs; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find( - (pc: any) => pc.id === tabsComponentId, - ); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭 업데이트 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === tabsComponentId ? updatedTabsComponent : c, - ), - }; - } - - // 선택된 컴포넌트 정보 업데이트 - if (updatedTabs) { - const updatedComp = updatedTabs - .find((t: any) => t.id === tabId) - ?.components?.find((c: any) => c.id === componentId); - if (updatedComp) { - setSelectedTabComponentInfo((prev) => - prev ? { ...prev, component: updatedComp } : null, - ); - } - } - - return newLayout; - }); - }; - - // 탭 내부 컴포넌트 삭제 핸들러 (중첩 구조 지원) - const deleteTabComponent = (componentId: string) => { - const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = - selectedTabComponentInfo; - - // 탭 컴포넌트에서 특정 컴포넌트 삭제 - const updateTabsComponentForDelete = (tabsComponent: any) => { - const currentConfig = tabsComponent.componentConfig || {}; - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).filter((c: any) => c.id !== componentId), - }; - } - return tab; - }); - - return { - ...tabsComponent, - componentConfig: { ...currentConfig, tabs: updatedTabs }, - }; - }; - - setLayout((prevLayout) => { - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭에서 삭제 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find( - (pc: any) => pc.id === tabsComponentId, - ); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent); - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭에서 삭제 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent); - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === tabsComponentId ? updatedTabsComponent : c, - ), - }; - } - - setSelectedTabComponentInfo(null); - return newLayout; - }); - }; - - return ( -
-
- 탭 내부 컴포넌트 - -
-
- 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - onStyleChange={(style) => { - updateTabComponentProperty(tabComp.id, "style", style); - }} - allComponents={layout.components} - menuObjid={menuObjid} - /> -
-
- ); - })() - ) : selectedPanelComponentInfo ? ( - // 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용 - (() => { - const panelComp = selectedPanelComponentInfo.component; - - // 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환 - const panelComponentAsComponentData: ComponentData = { - id: panelComp.id, - type: "component", - componentType: panelComp.componentType, - label: panelComp.label, - position: panelComp.position || { x: 0, y: 0 }, - size: panelComp.size || { width: 200, height: 100 }, - componentConfig: panelComp.componentConfig || {}, - style: panelComp.style || {}, - } as ComponentData; - - // 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러 - const updatePanelComponentProperty = (componentId: string, path: string, value: any) => { - const { splitPanelId, panelSide } = selectedPanelComponentInfo; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - - console.log("🔧 updatePanelComponentProperty 호출:", { - componentId, - path, - value, - splitPanelId, - panelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 - const setNestedValue = (obj: any, pathStr: string, val: any): any => { - const result = JSON.parse(JSON.stringify(obj)); - const parts = pathStr.split("."); - let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = val; - return result; - }; - - setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; - - const currentConfig = (splitPanelComponent as any).componentConfig || {}; - const panelConfig = currentConfig[panelKey] || {}; - const components = panelConfig.components || []; - - // 해당 컴포넌트 찾기 - const targetCompIndex = components.findIndex((c: any) => c.id === componentId); - if (targetCompIndex === -1) return prevLayout; - - // 🆕 안전한 깊은 경로 업데이트 사용 - const targetComp = components[targetCompIndex]; - const updatedComp = - path === "style" - ? { ...targetComp, style: value } - : setNestedValue(targetComp, path, value); - - console.log("🔧 분할 패널 컴포넌트 업데이트 결과:", updatedComp); - - const updatedComponents = [ - ...components.slice(0, targetCompIndex), - updatedComp, - ...components.slice(targetCompIndex + 1), - ]; - - const updatedComponent = { - ...splitPanelComponent, - componentConfig: { - ...currentConfig, - [panelKey]: { - ...panelConfig, - components: updatedComponents, - }, - }, - }; - - // selectedPanelComponentInfo 업데이트 - setSelectedPanelComponentInfo((prev) => - prev ? { ...prev, component: updatedComp } : null, - ); - - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), - }; - }); - }; - - // 분할 패널 내부 컴포넌트 삭제 핸들러 - const deletePanelComponent = (componentId: string) => { - const { splitPanelId, panelSide } = selectedPanelComponentInfo; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - - setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; - - const currentConfig = (splitPanelComponent as any).componentConfig || {}; - const panelConfig = currentConfig[panelKey] || {}; - const components = panelConfig.components || []; - - const updatedComponents = components.filter((c: any) => c.id !== componentId); - - const updatedComponent = { - ...splitPanelComponent, - componentConfig: { - ...currentConfig, - [panelKey]: { - ...panelConfig, - components: updatedComponents, - }, - }, - }; - - setSelectedPanelComponentInfo(null); - - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), - }; - }); - }; - - return ( -
-
- - 분할 패널 ({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"}) - 컴포넌트 - - -
-
- 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - onStyleChange={(style) => { - updatePanelComponentProperty(panelComp.id, "style", style); - }} - allComponents={layout.components} - menuObjid={menuObjid} - /> -
-
- ); - })() - ) : ( - 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - dragState={dragState} - onStyleChange={(style) => { - if (selectedComponent) { - updateComponentProperty(selectedComponent.id, "style", style); - } - }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - /> - )} -
-
-
-
- )} - - {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */} -
- {/* Pan 모드 안내 - 제거됨 */} - {/* 줌 레벨 표시 */} -
- 🔍 {Math.round(zoomLevel * 100)}% -
- {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */} - {(() => { - // 선택된 컴포넌트들 - const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); - - // 버튼 컴포넌트만 필터링 - const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp])); - - // 플로우 그룹에 속한 버튼이 있는지 확인 - const hasFlowGroupButton = selectedButtons.some((btn) => { - const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig; - return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId; - }); - - // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시 - const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton); - - if (!shouldShow) return null; - - return ( -
-
-
- - - - - - {selectedButtons.length}개 버튼 선택됨 -
- - {/* 그룹 생성 버튼 (2개 이상 선택 시) */} - {selectedButtons.length >= 2 && ( - - )} - - {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */} - {hasFlowGroupButton && ( - - )} - - {/* 상태 표시 */} - {hasFlowGroupButton &&

✓ 플로우 그룹 버튼

} -
-
- ); - })()} - {/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */} - {activeLayerId > 1 && ( -
-
- - 레이어 {activeLayerId} 편집 중 - {activeLayerZone && ( - - (캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name}) - - )} - {!activeLayerZone && ( - - (조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요) - - )} - -
- )} - - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} - {(() => { - // 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤 - const activeRegion = activeLayerId > 1 ? activeLayerZone : null; - const canvasW = activeRegion ? activeRegion.width : screenResolution.width; - const canvasH = activeRegion ? activeRegion.height : screenResolution.height; - - return ( -
- {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */} -
-
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - // Pan 모드가 아닐 때만 다중 선택 시작 - if (e.target === e.currentTarget && !isPanMode) { - startSelectionDrag(e); - } - }} - onMouseMove={(e) => { - // 영역 이동/리사이즈 처리 - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseMove(e); - } - }} - onMouseUp={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onMouseLeave={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onDropCapture={(e) => { - // 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지 - e.preventDefault(); - handleDrop(e); - }} - > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} - - {/* 컴포넌트들 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - // visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시 - const topLevelComponents = visibleComponents.filter((component) => !component.parentId); - - // auto-compact 모드의 버튼들을 그룹별로 묶기 - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - - topLevelComponents.forEach((component) => { - const isButton = - component.type === "button" || - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)); - - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; - - if ( - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; - } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); - } - } - }); - - // 그룹에 속하지 않은 일반 컴포넌트들 - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - - // 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리) - // console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() }); - - return ( - <> - {/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */} - {/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */} - {activeLayerId === 1 && zones.map((zone) => { - const layerId = zone.zone_id; // 렌더링용 ID - const region = zone; - const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"]; - const handleCursors: Record = { - nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize", - n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize", - }; - const handlePositions: Record = { - nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 }, - sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 }, - n: { top: -4, left: "50%", transform: "translateX(-50%)" }, - s: { bottom: -4, left: "50%", transform: "translateX(-50%)" }, - e: { top: "50%", right: -4, transform: "translateY(-50%)" }, - w: { top: "50%", left: -4, transform: "translateY(-50%)" }, - }; - // 테두리 두께 (이동 핸들 영역) - const borderWidth = 6; - return ( -
- {/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */} - {/* 상단 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 하단 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 좌측 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 우측 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 라벨 */} - handleRegionMouseDown(e, String(layerId), "move")} - > - Zone {zone.zone_id} - {zone.zone_name} - - {/* 리사이즈 핸들 */} - {resizeHandles.map((handle) => ( -
handleRegionMouseDown(e, String(layerId), "resize", handle)} - /> - ))} - {/* 삭제 버튼 */} - -
- ); - })} - - - {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; - - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); - - let displayComponent = component; - - if (isBeingDragged) { - if (isDraggingThis) { - // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 - displayComponent = { - ...component, - position: dragState.currentPosition, - style: { - ...component.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 - const originalComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === component.id, - ); - if (originalComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayComponent = { - ...component, - position: { - x: originalComponent.position.x + deltaX, - y: originalComponent.position.y + deltaY, - z: originalComponent.position.z || 1, - } as Position, - style: { - ...component.style, - opacity: 0.8, - transition: "none", - zIndex: 40, // 주 컴포넌트보다 약간 낮게 - }, - }; - } - } - } - - // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 - const globalFileState = - typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; - const componentFiles = (component as any).uploadedFiles || []; - const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; - // 🆕 style 변경 시 리렌더링을 위한 key 추가 - const styleKey = - component.style?.labelDisplay !== undefined - ? `label-${component.style.labelDisplay}` - : ""; - const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`; - - // 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리) - // if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); } - - // 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지 - const componentWithLabel = { - ...displayComponent, - _labelDisplayKey: component.style?.labelDisplay, - }; - - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, - }, - }; - } - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - // 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등) - onUpdateComponent={(updatedComponent) => { - const updatedComponents = layout.components.map((comp) => - comp.id === updatedComponent.id ? updatedComponent : comp, - ); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - }} - // 🆕 리사이즈 핸들러 (10px 스냅 적용됨) - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - // saveToHistory는 별도로 호출 (prevLayout 기반) - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - // 🆕 탭 내부 컴포넌트 선택 핸들러 - onSelectTabComponent={(tabId, compId, comp) => - handleSelectTabComponent(component.id, tabId, compId, comp) - } - selectedTabComponentId={ - selectedTabComponentInfo?.tabsComponentId === component.id - ? selectedTabComponentInfo.componentId - : undefined - } - // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 - onSelectPanelComponent={(panelSide, compId, comp) => - handleSelectPanelComponent(component.id, panelSide, compId, comp) - } - selectedPanelComponentId={ - selectedPanelComponentInfo?.splitPanelId === component.id - ? selectedPanelComponentInfo.componentId - : undefined - } - > - {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || - component.type === "container" || - component.type === "area" || - component.type === "component") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트에도 드래그 피드백 적용 - const isChildDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === child.id; - const isChildBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); - - let displayChild = child; - - if (isChildBeingDragged) { - if (isChildDraggingThis) { - // 주 드래그 자식 컴포넌트 - displayChild = { - ...child, - position: dragState.currentPosition, - style: { - ...child.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 자식 컴포넌트들 - const originalChildComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === child.id, - ); - if (originalChildComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayChild = { - ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, - style: { - ...child.style, - opacity: 0.8, - transition: "none", - zIndex: 8888, - }, - }; - } - } - } - - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; - - return ( - f.objid) || [])}`} - component={relativeChildComponent} - isSelected={ - selectedComponent?.id === child.id || - groupState.selectedComponents.includes(child.id) - } - isDesignMode={true} // 편집 모드로 설정 - onClick={(e) => handleComponentClick(child, e)} - onDoubleClick={(e) => handleComponentDoubleClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - // 🆕 자식 컴포넌트 리사이즈 핸들러 - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔧 그룹의 위치 및 크기 계산 - // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 - // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - const align = groupConfig.groupAlign || "start"; - - const groupPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼들의 실제 크기 계산 - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - // 가로 정렬: 모든 버튼의 너비 + 간격 - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - // 세로 정렬 - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); - } - - // 🆕 그룹 전체가 선택되었는지 확인 - const isGroupSelected = buttons.every( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - const hasAnySelected = buttons.some( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - - return ( -
- { - // 드래그 피드백 - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === button.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === button.id); - - let displayButton = button; - - if (isBeingDragged) { - if (isDraggingThis) { - displayButton = { - ...button, - position: dragState.currentPosition, - style: { - ...button.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } - } - - // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리) - const relativeButton = { - ...displayButton, - position: { - x: 0, - y: 0, - z: displayButton.position.z || 1, - }, - }; - - return ( -
{ - // 클릭이 아닌 드래그인 경우에만 드래그 시작 - e.preventDefault(); - e.stopPropagation(); - - const startX = e.clientX; - const startY = e.clientY; - let isDragging = false; - let dragStarted = false; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = Math.abs(moveEvent.clientX - startX); - const deltaY = Math.abs(moveEvent.clientY - startY); - - // 5픽셀 이상 움직이면 드래그로 간주 - if ((deltaX > 5 || deltaY > 5) && !dragStarted) { - isDragging = true; - dragStarted = true; - - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - - // 드래그 시작 - startComponentDrag(button, e as any); - } - }; - - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - // 드래그가 아니면 클릭으로 처리 - if (!isDragging) { - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - handleComponentClick(button, e); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - onDoubleClick={(e) => { - e.stopPropagation(); - handleComponentDoubleClick(button, e); - }} - className={ - selectedComponent?.id === button.id || - groupState.selectedComponents.includes(button.id) - ? "outline-1 outline-offset-1 outline-blue-400" - : "" - } - > - {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} -
- {}} - /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} - - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} - - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
-
- -
-

캔버스가 비어있습니다

-

- 좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요 -

-
-

- 단축키: T(테이블), M(템플릿), P(속성), S(스타일), - R(격자), D(상세설정), E(해상도) -

-

- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), - Ctrl+Z(실행취소), Delete(삭제) -

-

- ⚠️ - 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 -

-
-
-
- )} -
-
-
- ); /* 🔥 줌 래퍼 닫기 */ - })()} -
-
{" "} - {/* 메인 컨테이너 닫기 */} - {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} - - {/* 모달들 */} - {/* 메뉴 할당 모달 */} - {showMenuAssignmentModal && selectedScreen && ( - setShowMenuAssignmentModal(false)} - onAssignmentComplete={() => { - // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 - // setShowMenuAssignmentModal(false); - // toast.success("메뉴에 화면이 할당되었습니다."); - }} - onBackToList={onBackToList} - /> - )} - {/* 파일첨부 상세 모달 */} - {showFileAttachmentModal && selectedFileComponent && ( - { - setShowFileAttachmentModal(false); - setSelectedFileComponent(null); - }} - component={selectedFileComponent} - screenId={selectedScreen.screenId} - /> - )} - {/* 다국어 설정 모달 */} - setShowMultilangSettingsModal(false)} - components={layout.components} - onSave={async (updates) => { - if (updates.length === 0) { - toast.info("저장할 변경사항이 없습니다."); - return; - } - - try { - // 공통 유틸 사용하여 매핑 적용 - const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor"); - - // 매핑 형식 변환 - const mappings = updates.map((u) => ({ - componentId: u.componentId, - keyId: u.langKeyId, - langKey: u.langKey, - })); - - // 레이아웃 업데이트 - const updatedComponents = applyMultilangMappings(layout.components, mappings); - setLayout((prev) => ({ - ...prev, - components: updatedComponents, - })); - - toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); - } catch (error) { - console.error("다국어 설정 저장 실패:", error); - toast.error("다국어 설정 저장 중 오류가 발생했습니다."); - } - }} - /> - {/* 단축키 도움말 모달 */} - setShowShortcutsModal(false)} - /> -
- - - - ); -} \ No newline at end of file +서; diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 98302169..a076b867 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -49,7 +49,6 @@ export function ComponentsPanel({ () => [ // v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 - // v2-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 // v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 @@ -57,6 +56,23 @@ export function ComponentsPanel({ // v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용 // v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시 // v2-hierarchy 제거 - 현재 미사용 + { + id: "v2-select", + name: "V2 선택", + description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원", + category: "input" as ComponentCategory, + tags: ["select", "dropdown", "combobox", "v2"], + defaultSize: { width: 300, height: 40 }, + defaultConfig: { + mode: "dropdown", + source: "static", + multiple: false, + searchable: false, + placeholder: "선택하세요", + options: [], + allowClear: true, + }, + }, { id: "v2-repeater", name: "리피터 그리드", @@ -65,7 +81,7 @@ export function ComponentsPanel({ tags: ["repeater", "table", "modal", "button", "v2", "v2"], defaultSize: { width: 600, height: 300 }, }, - ] as ComponentDefinition[], + ] as unknown as ComponentDefinition[], [], ); @@ -126,6 +142,7 @@ export function ComponentsPanel({ "section-card", // → v2-section-card "location-swap-selector", // → v2-location-swap-selector "rack-structure", // → v2-rack-structure + "v2-select", // → v2-select (아래 v2Components에서 별도 처리) "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "repeat-container", // → v2-repeat-container "repeat-screen-modal", // → v2-repeat-screen-modal diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 832f2ddb..c4bd0925 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -71,11 +71,13 @@ const DropdownSelect = forwardRef - {options.map((option) => ( - - {option.label} - - ))} + {options + .filter((option) => option.value !== "") + .map((option) => ( + + {option.label} + + ))} ); diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index e67accee..ce3b3dbd 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -87,9 +87,9 @@ export const V2SelectConfigPanel: React.FC = ({ config updateConfig("options", newOptions); }; - const updateOption = (index: number, field: "value" | "label", value: string) => { + const updateOptionValue = (index: number, value: string) => { const newOptions = [...options]; - newOptions[index] = { ...newOptions[index], [field]: value }; + newOptions[index] = { ...newOptions[index], value, label: value }; updateConfig("options", newOptions); }; @@ -139,7 +139,7 @@ export const V2SelectConfigPanel: React.FC = ({ config
{/* 정적 옵션 관리 */} - {config.source === "static" && ( + {(config.source || "static") === "static" && (
@@ -148,19 +148,13 @@ export const V2SelectConfigPanel: React.FC = ({ config 추가
-
+
{options.map((option: any, index: number) => ( -
+
updateOption(index, "value", e.target.value)} - placeholder="값" - className="h-7 flex-1 text-xs" - /> - updateOption(index, "label", e.target.value)} - placeholder="표시 텍스트" + onChange={(e) => updateOptionValue(index, e.target.value)} + placeholder={`옵션 ${index + 1}`} className="h-7 flex-1 text-xs" />