From 9bf879e29d25a1c9b1423a86e8b7fdd7d2fe54c7 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Sep 2025 15:20:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컴포넌트 드래그앤드롭 시스템 완성 - 속성 편집 및 실시간 미리보기 기능 - 버튼 시스템 통합 (유니버설 버튼) - 격자 시스템 및 해상도 설정 패널 - 상세설정 패널 구현 - 스타일 편집기 최적화 - 라벨 처리 시스템 개선 --- backend-node/src/app.ts | 39 +- .../components/screen/DesignerToolbar.tsx | 14 + .../screen/InteractiveScreenViewer.tsx | 113 +++- frontend/components/screen/ScreenDesigner.tsx | 624 +++++++++++------- .../screen/panels/ButtonConfigPanel.tsx | 75 ++- .../screen/panels/DetailSettingsPanel.tsx | 179 ++--- .../components/screen/panels/GridPanel.tsx | 48 +- .../screen/panels/ResolutionPanel.tsx | 194 ++++++ frontend/lib/utils/gridUtils.ts | 18 +- frontend/types/screen.ts | 29 + 10 files changed, 974 insertions(+), 359 deletions(-) create mode 100644 frontend/components/screen/panels/ResolutionPanel.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 53a00c0b..9e5c7e18 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -30,10 +30,37 @@ app.use(express.urlencoded({ extended: true, limit: "10mb" })); // CORS 설정 app.use( cors({ - origin: config.cors.origin, + origin: function (origin, callback) { + const allowedOrigins = config.cors.origin + .split(",") + .map((url) => url.trim()); + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + if (allowedOrigins.indexOf(origin) !== -1) { + return callback(null, true); + } else { + console.log(`CORS rejected origin: ${origin}`); + return callback( + new Error( + "CORS policy does not allow access from the specified Origin." + ), + false + ); + } + }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Requested-With", + "Accept", + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + ], + preflightContinue: false, + optionsSuccessStatus: 200, }) ); @@ -86,11 +113,13 @@ app.use(errorHandler); // 서버 시작 const PORT = config.port; +const HOST = config.host; -app.listen(PORT, () => { - logger.info(`🚀 Server is running on port ${PORT}`); +app.listen(PORT, HOST, () => { + logger.info(`🚀 Server is running on ${HOST}:${PORT}`); logger.info(`📊 Environment: ${config.nodeEnv}`); - logger.info(`🔗 Health check: http://localhost:${PORT}/health`); + logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); + logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); }); export default app; diff --git a/frontend/components/screen/DesignerToolbar.tsx b/frontend/components/screen/DesignerToolbar.tsx index 520b1abc..40b15ce7 100644 --- a/frontend/components/screen/DesignerToolbar.tsx +++ b/frontend/components/screen/DesignerToolbar.tsx @@ -16,6 +16,7 @@ import { ArrowLeft, Cog, Layout, + Monitor, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -152,6 +153,19 @@ export const DesignerToolbar: React.FC = ({ D + + {/* 우측: 액션 버튼들 */} diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 32cea32b..9c955385 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -8,6 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { CalendarIcon } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -30,6 +31,7 @@ import { import { InteractiveDataTable } from "./InteractiveDataTable"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { useParams } from "next/navigation"; +import { screenApi } from "@/lib/api/screen"; interface InteractiveScreenViewerProps { component: ComponentData; @@ -53,6 +55,37 @@ export const InteractiveScreenViewer: React.FC = ( }) => { const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); + + // 팝업 화면 상태 + const [popupScreen, setPopupScreen] = useState<{ + screenId: number; + title: string; + size: string; + } | null>(null); + + // 팝업 화면 레이아웃 상태 + const [popupLayout, setPopupLayout] = useState([]); + const [popupLoading, setPopupLoading] = useState(false); + + // 팝업 화면 레이아웃 로드 + React.useEffect(() => { + if (popupScreen) { + const loadPopupLayout = async () => { + try { + setPopupLoading(true); + const layout = await screenApi.getLayout(popupScreen.screenId); + setPopupLayout(layout.components || []); + } catch (error) { + console.error("팝업 화면 레이아웃 로드 실패:", error); + setPopupLayout([]); + } finally { + setPopupLoading(false); + } + }; + + loadPopupLayout(); + } + }, [popupScreen]); // 실제 사용할 폼 데이터 (외부에서 제공된 경우 우선 사용) const formData = externalFormData || localFormData; @@ -948,8 +981,15 @@ export const InteractiveScreenViewer: React.FC = ( // 팝업 액션 const handlePopupAction = () => { - if (config?.popupTitle && config?.popupContent) { - // 커스텀 모달 대신 기본 alert 사용 (향후 모달 컴포넌트로 교체 가능) + if (config?.popupScreenId) { + // 화면 팝업 열기 + setPopupScreen({ + screenId: config.popupScreenId, + title: config.popupTitle || "상세 정보", + size: config.popupSize || "md", + }); + } else if (config?.popupTitle && config?.popupContent) { + // 텍스트 팝업 표시 alert(`${config.popupTitle}\n\n${config.popupContent}`); } else { alert("팝업을 표시합니다."); @@ -1083,18 +1123,63 @@ export const InteractiveScreenViewer: React.FC = ( marginBottom: component.style?.labelMarginBottom || "4px", }; - return ( -
- {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} - {shouldShowLabel && ( -
- {labelText} - {component.required && *} -
- )} + // 팝업 크기 설정 + const getPopupMaxWidth = (size: string) => { + switch (size) { + case "sm": return "max-w-md"; + case "md": return "max-w-2xl"; + case "lg": return "max-w-4xl"; + case "xl": return "max-w-6xl"; + default: return "max-w-2xl"; + } + }; - {/* 실제 위젯 */} -
{renderInteractiveWidget(component)}
-
+ return ( + <> +
+ {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} + {shouldShowLabel && ( +
+ {labelText} + {component.required && *} +
+ )} + + {/* 실제 위젯 */} +
{renderInteractiveWidget(component)}
+
+ + {/* 팝업 화면 모달 */} + setPopupScreen(null)}> + + + {popupScreen?.title || "상세 정보"} + + +
+ {popupLoading ? ( +
+
화면을 불러오는 중...
+
+ ) : popupLayout.length > 0 ? ( +
+ {popupLayout.map((popupComponent) => ( + + ))} +
+ ) : ( +
+
화면 데이터가 없습니다.
+
+ )} +
+
+
+ ); }; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 24433a26..a99d94ae 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -11,6 +11,8 @@ import { Position, ColumnInfo, GridSettings, + ScreenResolution, + SCREEN_RESOLUTIONS, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { @@ -45,6 +47,7 @@ import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import PropertiesPanel from "./panels/PropertiesPanel"; import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import GridPanel from "./panels/GridPanel"; +import ResolutionPanel from "./panels/ResolutionPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; interface ScreenDesignerProps { @@ -102,6 +105,14 @@ const panelConfigs: PanelConfig[] = [ defaultHeight: 400, // autoHeight 시작점 shortcutKey: "d", }, + { + id: "resolution", + title: "해상도 설정", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 400, + shortcutKey: "e", // resolution의 e + }, ]; export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { @@ -122,6 +133,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }); const [isSaving, setIsSaving] = useState(false); + // 해상도 설정 상태 + const [screenResolution, setScreenResolution] = useState( + SCREEN_RESOLUTIONS[0], // 기본값: Full HD + ); + const [selectedComponent, setSelectedComponent] = useState(null); // 클립보드 상태 @@ -171,9 +187,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const gridInfo = useMemo(() => { if (!layout.gridSettings) return null; - // 캔버스 크기 계산 - let width = canvasSize.width || window.innerWidth - 100; - let height = canvasSize.height || window.innerHeight - 200; + // 캔버스 크기 계산 (해상도 설정 우선) + let width = screenResolution.width; + let height = screenResolution.height; + + // 해상도가 설정되지 않은 경우 기본값 사용 + if (!width || !height) { + width = canvasSize.width || window.innerWidth - 100; + height = canvasSize.height || window.innerHeight - 200; + } if (canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); @@ -187,21 +209,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }); - }, [layout.gridSettings, canvasSize]); + }, [layout.gridSettings, canvasSize, screenResolution]); // 격자 라인 생성 const gridLines = useMemo(() => { if (!gridInfo || !layout.gridSettings?.showGrid) return []; - // 캔버스 크기 계산 - let width = window.innerWidth - 100; - let height = window.innerHeight - 200; - - if (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - width = rect.width || width; - height = rect.height || height; - } + // 캔버스 크기는 해상도 크기 사용 + const width = screenResolution.width; + const height = screenResolution.height; const lines = generateGridLines(width, height, { columns: layout.gridSettings.columns, @@ -217,7 +233,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ]; return allLines; - }, [gridInfo, layout.gridSettings]); + }, [gridInfo, layout.gridSettings, screenResolution]); // 필터된 테이블 목록 const filteredTables = useMemo(() => { @@ -541,6 +557,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...response.gridSettings, // 기존 설정이 있으면 덮어쓰기 }, }; + + // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 + if (response.screenResolution) { + setScreenResolution(response.screenResolution); + console.log("💾 저장된 해상도 불러옴:", response.screenResolution); + } else { + // 기본 해상도 (Full HD) + const defaultResolution = + SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0]; + setScreenResolution(defaultResolution); + console.log("🔧 기본 해상도 적용:", defaultResolution); + } + setLayout(layoutWithDefaultGrid); setHistory([layoutWithDefaultGrid]); setHistoryIndex(0); @@ -560,9 +589,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const newLayout = { ...layout, gridSettings: newGridSettings }; // 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정 - if (newGridSettings.snapToGrid && canvasSize.width > 0) { - // 새로운 격자 설정으로 격자 정보 재계산 - const newGridInfo = calculateGridInfo(canvasSize.width, canvasSize.height, { + if (newGridSettings.snapToGrid && screenResolution.width > 0) { + // 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준) + const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: newGridSettings.columns, gap: newGridSettings.gap, padding: newGridSettings.padding, @@ -602,7 +631,65 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setLayout(newLayout); saveToHistory(newLayout); }, - [layout, canvasSize, saveToHistory], + [layout, screenResolution, saveToHistory], + ); + + // 해상도 변경 핸들러 + const handleResolutionChange = useCallback( + (newResolution: ScreenResolution) => { + setScreenResolution(newResolution); + console.log("📱 해상도 변경:", newResolution); + + // 레이아웃에 해상도 정보 즉시 반영 + const updatedLayout = { ...layout, screenResolution: newResolution }; + + // 격자 스냅이 활성화된 경우, 기존 컴포넌트들을 새로운 해상도의 격자에 맞게 조정 + if (layout.gridSettings?.snapToGrid && layout.components.length > 0) { + // 새로운 해상도로 격자 정보 재계산 + const newGridInfo = calculateGridInfo(newResolution.width, newResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + + const gridUtilSettings = { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid, + }; + + const adjustedComponents = layout.components.map((comp) => { + const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); + const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); + + // gridColumns가 없거나 범위를 벗어나면 자동 조정 + let adjustedGridColumns = comp.gridColumns; + if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) { + adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); + } + + return { + ...comp, + position: snappedPosition, + size: snappedSize, + gridColumns: adjustedGridColumns, + }; + }); + + const newLayout = { ...updatedLayout, components: adjustedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + console.log("해상도 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개"); + console.log("새로운 격자 정보:", newGridInfo); + } else { + // 격자 조정이 없는 경우에도 해상도 정보가 포함된 레이아웃 저장 + setLayout(updatedLayout); + saveToHistory(updatedLayout); + } + }, + [layout, saveToHistory], ); // 저장 @@ -611,7 +698,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD try { setIsSaving(true); - await screenApi.saveLayout(selectedScreen.screenId, layout); + // 해상도 정보를 포함한 레이아웃 데이터 생성 + const layoutWithResolution = { + ...layout, + screenResolution: screenResolution, + }; + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); toast.success("화면이 저장되었습니다."); } catch (error) { console.error("저장 실패:", error); @@ -2194,7 +2286,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD if (layout.components.length > 0 && selectedScreen?.screenId) { setIsSaving(true); try { - await screenApi.saveLayout(selectedScreen.screenId, layout); + // 해상도 정보를 포함한 레이아웃 데이터 생성 + const layoutWithResolution = { + ...layout, + screenResolution: screenResolution, + }; + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); toast.success("레이아웃이 저장되었습니다."); } catch (error) { console.error("레이아웃 저장 실패:", error); @@ -2259,230 +2356,255 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD isSaving={isSaving} /> - {/* 메인 캔버스 영역 (전체 화면) */} -
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - if (e.target === e.currentTarget) { - startSelectionDrag(e); - } - }} - onDrop={handleDrop} - onDragOver={handleDragOver} - > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} - - {/* 컴포넌트들 */} - {layout.components - .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 - .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: 9999, - }, - }; - } 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: 8888, // 주 컴포넌트보다 약간 낮게 - }, - }; - } - } - } - - return ( -
- handleComponentClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - > - {/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */} - {(component.type === "group" || component.type === "container") && - 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: 9999, - }, - }; - } 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, - }, - }; - } - } - } - - return ( -
- handleComponentClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - /> -
- ); - })} -
-
- ); - })} - - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} - - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
- -

캔버스가 비어있습니다

-

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

-

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

-

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

-

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

-
+ {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) */} +
+ {/* 해상도 정보 표시 */} +
+
+ + {screenResolution.name} ({screenResolution.width} × {screenResolution.height}) +
- )} +
+ + {/* 실제 작업 캔버스 (해상도 크기) */} +
+
{ + if (e.target === e.currentTarget && !selectionDrag.wasSelecting) { + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }} + onMouseDown={(e) => { + if (e.target === e.currentTarget) { + startSelectionDrag(e); + } + }} + onDrop={handleDrop} + onDragOver={handleDragOver} + > + {/* 격자 라인 */} + {gridLines.map((line, index) => ( +
+ ))} + + {/* 컴포넌트들 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .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: 9999, + }, + }; + } 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: 8888, // 주 컴포넌트보다 약간 낮게 + }, + }; + } + } + } + + return ( +
+ handleComponentClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + > + {/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */} + {(component.type === "group" || component.type === "container") && + 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: 9999, + }, + }; + } 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, + }, + }; + } + } + } + + return ( +
+ handleComponentClick(child, e)} + onDragStart={(e) => startComponentDrag(child, e)} + onDragEnd={endDrag} + /> +
+ ); + })} +
+
+ ); + })} + + {/* 드래그 선택 영역 */} + {selectionDrag.isSelecting && ( +
+ )} + + {/* 빈 캔버스 안내 */} + {layout.components.length === 0 && ( +
+
+ +

캔버스가 비어있습니다

+

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

+

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

+

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

+

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

+
+
+ )} +
+
{/* 플로팅 패널들 */} @@ -2636,6 +2758,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }; updateGridSettings(defaultSettings); }} + screenResolution={screenResolution} /> @@ -2657,6 +2780,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD /> + closePanel("resolution")} + position="right" + width={320} + height={400} + autoHeight={true} + > +
+ +
+
+ {/* 그룹 생성 툴바 (필요시) */} {false && groupState.selectedComponents.length > 1 && (
diff --git a/frontend/components/screen/panels/ButtonConfigPanel.tsx b/frontend/components/screen/panels/ButtonConfigPanel.tsx index a77bdc54..378a2f37 100644 --- a/frontend/components/screen/panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/panels/ButtonConfigPanel.tsx @@ -22,7 +22,8 @@ import { Settings, AlertTriangle, } from "lucide-react"; -import { ButtonActionType, ButtonTypeConfig, WidgetComponent } from "@/types/screen"; +import { ButtonActionType, ButtonTypeConfig, WidgetComponent, ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; interface ButtonConfigPanelProps { component: WidgetComponent; @@ -61,6 +62,30 @@ export const ButtonConfigPanel: React.FC = ({ component, }; }); + // 화면 목록 상태 + const [screens, setScreens] = useState([]); + const [screensLoading, setScreensLoading] = useState(false); + + // 화면 목록 로드 함수 + const loadScreens = async () => { + try { + setScreensLoading(true); + const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기 + setScreens(response.data); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setScreensLoading(false); + } + }; + + // 팝업 액션 타입일 때 화면 목록 로드 + useEffect(() => { + if (localConfig.actionType === "popup") { + loadScreens(); + } + }, [localConfig.actionType]); + // 컴포넌트 변경 시 로컬 상태 동기화 useEffect(() => { const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {}; @@ -370,6 +395,33 @@ export const ButtonConfigPanel: React.FC = ({ component, 팝업 설정
+
+ + + {localConfig.popupScreenId && ( +

선택된 화면이 팝업으로 열립니다

+ )} +
= ({ component,
-
- -