diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index e1c76ad9..aa7213d9 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -15,7 +15,7 @@ services: DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 JWT_EXPIRES_IN: 24h - CORS_ORIGIN: https://v1.vexplor.com + CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com CORS_CREDENTIALS: "true" LOG_LEVEL: info ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 82a8d27c..658bc64c 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -11,6 +11,10 @@ import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; import { EditModal } from "@/components/screen/EditModal"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; +import { FlowButtonGroup } from "@/components/screen/widgets/FlowButtonGroup"; +import { FlowVisibilityConfig } from "@/types/control-management"; +import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; export default function ScreenViewPage() { const params = useParams(); @@ -219,98 +223,246 @@ export default function ScreenViewPage() { }} > {/* 최상위 컴포넌트들 렌더링 */} - {layout.components - .filter((component) => !component.parentId) - .map((component) => ( - {}} - screenId={screenId} - tableName={screen?.tableName} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - setSelectedRowsData(selectedData); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", { - dataCount: selectedData.length, - selectedData, - stepId, - }); - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - console.log("🔍 [page.tsx] 상태 업데이트 완료"); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - console.log("🔄 플로우 새로고침 요청됨"); - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - console.log("📝 폼 데이터 변경:", fieldName, "=", value); - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); - return ( - {}} - screenId={screenId} - tableName={screen?.tableName} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - setSelectedRowsData(selectedData); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value); - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ))} + 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)); + + return ( + <> + {/* 일반 컴포넌트들 */} + {regularComponents.map((component) => ( + {}} + screenId={screenId} + tableName={screen?.tableName} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + setSelectedRowsData(selectedData); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", { + dataCount: selectedData.length, + selectedData, + stepId, + }); + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + console.log("🔍 [page.tsx] 상태 업데이트 완료"); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨"); + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + console.log("🔄 플로우 새로고침 요청됨"); + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + console.log("📝 폼 데이터 변경:", fieldName, "=", value); + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || component.type === "container" || component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; + + return ( + {}} + screenId={screenId} + tableName={screen?.tableName} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + setSelectedRowsData(selectedData); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value); + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ); + })} + + ))} + + {/* 🆕 플로우 버튼 그룹들 */} + {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; + + // 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용 + const groupPosition = buttons.reduce( + (min, button) => ({ + x: Math.min(min.x, button.position.x), + y: Math.min(min.y, button.position.y), + z: min.z, + }), + { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 }, + ); + + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + 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); + } + + return ( +
+ { + const relativeButton = { + ...button, + position: { x: 0, y: 0, z: button.position.z || 1 }, + }; + + return ( +
+
+ {}} + screenId={screenId} + tableName={screen?.tableName} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + setSelectedRowsData(selectedData); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); + setFlowSelectedStepId(null); + }} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} ) : ( // 빈 화면일 때 diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e334cc54..bca6ca7a 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -14,6 +14,9 @@ import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; +import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; +import { FlowVisibilityConfig } from "@/types/control-management"; +import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 import "@/lib/registry/components/ButtonRenderer"; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 92d9d96d..b132dd42 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Database, Cog } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { ScreenDefinition, ComponentData, @@ -49,6 +50,7 @@ import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreviewDynamic"; import FloatingPanel from "./FloatingPanel"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import DesignerToolbar from "./DesignerToolbar"; import TablesPanel from "./panels/TablesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; @@ -58,6 +60,16 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import GridPanel from "./panels/GridPanel"; import ResolutionPanel from "./panels/ResolutionPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; +import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; +import { FlowVisibilityConfig } from "@/types/control-management"; +import { + areAllButtons, + generateGroupId, + groupButtons, + ungroupButtons, + findAllButtonGroups, +} from "@/lib/utils/flowButtonGroupUtils"; +import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog"; // 새로운 통합 UI 컴포넌트 import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; @@ -3467,6 +3479,127 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); }, [clipboard, layout, saveToHistory]); + // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) + // 🆕 플로우 버튼 그룹 다이얼로그 상태 + const [groupDialogOpen, setGroupDialogOpen] = useState(false); + + const handleFlowButtonGroup = useCallback(() => { + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + // 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가 + if (selectedComponents.length < 2) { + toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요"); + return; + } + + // 모두 버튼인지 확인 + if (!areAllButtons(selectedComponents)) { + toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다"); + return; + } + + // 🆕 다이얼로그 열기 + setGroupDialogOpen(true); + }, [layout, groupState.selectedComponents]); + + // 🆕 그룹 생성 확인 핸들러 + const handleGroupConfirm = useCallback( + (settings: { + direction: "horizontal" | "vertical"; + gap: number; + align: "start" | "center" | "end" | "space-between" | "space-around"; + }) => { + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + // 고유한 그룹 ID 생성 + const newGroupId = generateGroupId(); + + // 버튼들을 그룹으로 묶기 (설정 포함) + const groupedButtons = selectedComponents.map((button) => { + const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; + + return { + ...button, + webTypeConfig: { + ...(button as any).webTypeConfig, + flowVisibilityConfig: { + ...currentConfig, + enabled: true, + layoutBehavior: "auto-compact", + groupId: newGroupId, + groupDirection: settings.direction, + groupGap: settings.gap, + groupAlign: settings.align, + }, + }, + }; + }); + + // 레이아웃 업데이트 + const updatedComponents = layout.components.map((comp) => { + const grouped = groupedButtons.find((gb) => gb.id === comp.id); + return grouped || comp; + }); + + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, { + description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`, + }); + + console.log("✅ 플로우 버튼 그룹 생성 완료:", { + groupId: newGroupId, + buttonCount: selectedComponents.length, + buttons: selectedComponents.map((b) => b.id), + settings, + }); + }, + [layout, groupState.selectedComponents, saveToHistory], + ); + + // 🆕 플로우 버튼 그룹 해제 + const handleFlowButtonUngroup = useCallback(() => { + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + if (selectedComponents.length === 0) { + toast.error("그룹 해제할 버튼을 선택해주세요"); + return; + } + + // 버튼이 아닌 것 필터링 + const buttons = selectedComponents.filter((comp) => areAllButtons([comp])); + + if (buttons.length === 0) { + toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다"); + return; + } + + // 그룹 해제 + const ungroupedButtons = ungroupButtons(buttons); + + // 레이아웃 업데이트 + const updatedComponents = layout.components.map((comp) => { + const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id); + return ungrouped || comp; + }); + + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + toast.success(`${buttons.length}개의 버튼 그룹이 해제되었습니다`); + }, [layout, groupState.selectedComponents, saveToHistory]); + // 그룹 생성 (임시 비활성화) const handleGroupCreate = useCallback( (componentIds: string[], title: string, style?: any) => { @@ -4181,6 +4314,86 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
🔍 {Math.round(zoomLevel * 100)}%
+ {/* 🆕 플로우 버튼 그룹 제어 (다중 선택 시 표시) */} + {groupState.selectedComponents.length >= 2 && ( +
+
+
+ + + + + + {groupState.selectedComponents.length}개 선택됨 +
+ + + {areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id))) ? ( +

✓ 모두 버튼 컴포넌트

+ ) : ( +

⚠ 버튼만 그룹 가능

+ )} +
+
+ )} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
!component.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); + // auto-compact 모드의 버튼들을 그룹별로 묶기 + const buttonGroups: Record = {}; + const processedButtonIds = new Set(); - let displayComponent = component; + topLevelComponents.forEach((component) => { + const isButton = + component.type === "button" || + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)); - 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; + if (isButton) { + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as + | FlowVisibilityConfig + | undefined; - 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, // 주 컴포넌트보다 약간 낮게 - }, - }; + 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); } } + }); - // 전역 파일 상태도 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}`; + // 그룹에 속하지 않은 일반 컴포넌트들 + const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); + return ( + <> + {/* 일반 컴포넌트들 */} + {regularComponents.map((component) => { + const children = + component.type === "group" + ? layout.components.filter((child) => child.parentId === component.id) + : []; - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, + // 드래그 중 시각적 피드백 (다중 선택 지원) + 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, // 주 컴포넌트보다 약간 낮게 }, }; } - return comp; - }); + } + } - const newLayout = { - ...layout, - components: updatedComponents, - }; + // 전역 파일 상태도 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}`; - setLayout(newLayout); - saveToHistory(newLayout); + return ( + handleComponentClick(component, e)} + onDoubleClick={(e) => handleComponentDoubleClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + selectedScreen={selectedScreen} + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) + onConfigChange={(config) => { + // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - > - {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - 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, + // 컴포넌트의 componentConfig 업데이트 + const updatedComponents = layout.components.map((comp) => { + if (comp.id === component.id) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + ...config, }, }; - } 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; + } + return comp; + }); - displayChild = { - ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + console.log("✅ 컴포넌트 설정 업데이트 완료:", { + componentId: component.id, + updatedConfig: config, + }); + }} + > + {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + 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} + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (자식 컴포넌트용) + onConfigChange={(config) => { + // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); + // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 + }} + /> + ); + })} + + ); + })} + + {/* 🆕 플로우 버튼 그룹들 */} + {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; + + // 🔧 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용 + const groupPosition = buttons.reduce( + (min, button) => ({ + x: Math.min(min.x, button.position.x), + y: Math.min(min.y, button.position.y), + z: min.z, + }), + { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 }, + ); + + // 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + 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); + } + + 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: { - ...child.style, + ...button.style, opacity: 0.8, + transform: "scale(1.02)", transition: "none", - zIndex: 8888, + zIndex: 50, }, }; } } - } - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; + // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리) + const relativeButton = { + ...displayButton, + position: { + x: 0, + y: 0, + z: displayButton.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} - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - /> - ); - })} - - ); - })} + return ( +
{ + e.stopPropagation(); + handleComponentClick(button, e); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + handleComponentDoubleClick(button, e); + }} + className={ + selectedComponent?.id === button.id || + groupState.selectedComponents.includes(button.id) + ? "outline outline-2 outline-offset-2 outline-blue-500" + : "" + } + > + {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} +
+ {}} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} {/* 드래그 선택 영역 */} {selectionDrag.isSelecting && ( @@ -4495,6 +4887,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{" "} {/* 메인 컨테이너 닫기 */} + {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} + {/* 모달들 */} {/* 메뉴 할당 모달 */} {showMenuAssignmentModal && selectedScreen && ( diff --git a/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx b/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx index 23388e65..9d0f859f 100644 --- a/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx +++ b/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx @@ -9,7 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Workflow, Info, CheckCircle, XCircle, Loader2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react"; import { ComponentData } from "@/types/screen"; import { FlowVisibilityConfig } from "@/types/control-management"; import { getFlowById } from "@/lib/api/flow"; @@ -57,6 +58,16 @@ export const FlowVisibilityConfigPanel: React.FC currentConfig?.layoutBehavior || "auto-compact" ); + // 🆕 그룹 설정 (auto-compact 모드에서만 사용) + const [groupId, setGroupId] = useState(currentConfig?.groupId || `group-${Date.now()}`); + const [groupDirection, setGroupDirection] = useState<"horizontal" | "vertical">( + currentConfig?.groupDirection || "horizontal" + ); + const [groupGap, setGroupGap] = useState(currentConfig?.groupGap ?? 8); + const [groupAlign, setGroupAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">( + currentConfig?.groupAlign || "start" + ); + // 선택된 플로우의 스텝 목록 const [flowSteps, setFlowSteps] = useState([]); const [flowInfo, setFlowInfo] = useState(null); @@ -136,8 +147,8 @@ export const FlowVisibilityConfigPanel: React.FC loadFlowSteps(); }, [selectedFlowComponentId, flowWidgets]); - // 설정 저장 - const handleSave = () => { + // 🆕 설정 자동 저장 (즉시 적용) - 오버라이드 가능한 파라미터 지원 + const applyConfig = (overrides?: Partial) => { const config: FlowVisibilityConfig = { enabled, targetFlowComponentId: selectedFlowComponentId || "", @@ -147,49 +158,79 @@ export const FlowVisibilityConfigPanel: React.FC visibleSteps: mode === "whitelist" ? visibleSteps : undefined, hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined, layoutBehavior, + // 🆕 그룹 설정 (auto-compact 모드일 때만) + ...(layoutBehavior === "auto-compact" && { + groupId, + groupDirection, + groupGap, + groupAlign, + }), + // 오버라이드 적용 + ...overrides, }; + console.log("💾 [FlowVisibilityConfig] 설정 자동 저장:", { + componentId: component.id, + config, + timestamp: new Date().toISOString(), + }); + onUpdateProperty("webTypeConfig.flowVisibilityConfig", config); - toast.success("플로우 단계별 표시 설정이 저장되었습니다"); }; // 체크박스 토글 const toggleStep = (stepId: number) => { if (mode === "whitelist") { - setVisibleSteps((prev) => - prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId] - ); + const newSteps = visibleSteps.includes(stepId) + ? visibleSteps.filter((id) => id !== stepId) + : [...visibleSteps, stepId]; + setVisibleSteps(newSteps); + // 🆕 새 상태값을 직접 전달하여 즉시 저장 + applyConfig({ visibleSteps: newSteps }); } else if (mode === "blacklist") { - setHiddenSteps((prev) => - prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId] - ); + const newSteps = hiddenSteps.includes(stepId) + ? hiddenSteps.filter((id) => id !== stepId) + : [...hiddenSteps, stepId]; + setHiddenSteps(newSteps); + // 🆕 새 상태값을 직접 전달하여 즉시 저장 + applyConfig({ hiddenSteps: newSteps }); } }; // 빠른 선택 const selectAll = () => { if (mode === "whitelist") { - setVisibleSteps(flowSteps.map((s) => s.id)); + const newSteps = flowSteps.map((s) => s.id); + setVisibleSteps(newSteps); + applyConfig({ visibleSteps: newSteps }); } else if (mode === "blacklist") { setHiddenSteps([]); + applyConfig({ hiddenSteps: [] }); } }; const selectNone = () => { if (mode === "whitelist") { setVisibleSteps([]); + applyConfig({ visibleSteps: [] }); } else if (mode === "blacklist") { - setHiddenSteps(flowSteps.map((s) => s.id)); + const newSteps = flowSteps.map((s) => s.id); + setHiddenSteps(newSteps); + applyConfig({ hiddenSteps: newSteps }); } }; const invertSelection = () => { if (mode === "whitelist") { const allStepIds = flowSteps.map((s) => s.id); - setVisibleSteps(allStepIds.filter((id) => !visibleSteps.includes(id))); + const newSteps = allStepIds.filter((id) => !visibleSteps.includes(id)); + setVisibleSteps(newSteps); + applyConfig({ visibleSteps: newSteps }); } else if (mode === "blacklist") { const allStepIds = flowSteps.map((s) => s.id); - setHiddenSteps(allStepIds.filter((id) => !hiddenSteps.includes(id))); + const newSteps = allStepIds.filter((id) => !hiddenSteps.includes(id)); + setHiddenSteps(newSteps); + applyConfig({ hiddenSteps: newSteps }); } }; @@ -208,7 +249,14 @@ export const FlowVisibilityConfigPanel: React.FC {/* 활성화 체크박스 */}
- setEnabled(!!checked)} /> + { + setEnabled(!!checked); + setTimeout(() => applyConfig(), 0); + }} + /> @@ -219,7 +267,13 @@ export const FlowVisibilityConfigPanel: React.FC {/* 대상 플로우 선택 */}
- { + setSelectedFlowComponentId(value); + setTimeout(() => applyConfig(), 0); + }} + > @@ -243,7 +297,13 @@ export const FlowVisibilityConfigPanel: React.FC {/* 모드 선택 */}
- setMode(value)}> + { + setMode(value); + setTimeout(() => applyConfig(), 0); + }} + >