diff --git a/backend-node/uploads/company_COMPANY_2/README.txt b/backend-node/uploads/company_COMPANY_2/README.txt new file mode 100644 index 00000000..d05e1851 --- /dev/null +++ b/backend-node/uploads/company_COMPANY_2/README.txt @@ -0,0 +1,4 @@ +회사 코드: COMPANY_2 +생성일: 2025-09-11T02:07:40.033Z +폴더 구조: YYYY/MM/DD/파일명 +관리자: 시스템 자동 생성 \ No newline at end of file diff --git a/backend-node/uploads/company_COMPANY_3/README.txt b/backend-node/uploads/company_COMPANY_3/README.txt new file mode 100644 index 00000000..3b7f18c0 --- /dev/null +++ b/backend-node/uploads/company_COMPANY_3/README.txt @@ -0,0 +1,4 @@ +회사 코드: COMPANY_3 +생성일: 2025-09-11T02:08:06.303Z +폴더 구조: YYYY/MM/DD/파일명 +관리자: 시스템 자동 생성 \ No newline at end of file diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index a0d6c2d2..2c460598 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -28,6 +28,7 @@ interface RealtimePreviewProps { onDragEnd?: () => void; onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기 children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 + selectedScreen?: any; // 선택된 화면 정보 } // 동적 위젯 타입 아이콘 (레지스트리에서 조회) @@ -65,6 +66,7 @@ export const RealtimePreviewDynamic: React.FC = ({ onDragEnd, onGroupToggle, children, + selectedScreen, }) => { const { id, type, position, size, style: componentStyle } = component; @@ -120,6 +122,7 @@ export const RealtimePreviewDynamic: React.FC = ({ onDragStart={onDragStart} onDragEnd={onDragEnd} children={children} + selectedScreen={selectedScreen} /> diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 276a1212..cf18f209 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -306,9 +306,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD timestamp: new Date().toISOString(), }); + const targetComponent = layout.components.find((comp) => comp.id === componentId); + const isLayoutComponent = targetComponent?.type === "layout"; + + // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 + let positionDelta = { x: 0, y: 0 }; + if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { + const oldPosition = targetComponent.position; + let newPosition = { ...oldPosition }; + + if (path === "position.x") { + newPosition.x = value; + positionDelta.x = value - oldPosition.x; + } else if (path === "position.y") { + newPosition.y = value; + positionDelta.y = value - oldPosition.y; + } else if (path === "position") { + newPosition = value; + positionDelta.x = value.x - oldPosition.x; + positionDelta.y = value.y - oldPosition.y; + } + + console.log("📐 레이아웃 이동 감지:", { + layoutId: componentId, + oldPosition, + newPosition, + positionDelta, + }); + } + const pathParts = path.split("."); const updatedComponents = layout.components.map((comp) => { - if (comp.id !== componentId) return comp; + if (comp.id !== componentId) { + // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 + if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { + // 이 레이아웃의 존에 속한 컴포넌트인지 확인 + const isInLayoutZone = comp.parentId === componentId && comp.zoneId; + if (isInLayoutZone) { + console.log("🔄 존 컴포넌트 함께 이동:", { + componentId: comp.id, + zoneId: comp.zoneId, + oldPosition: comp.position, + delta: positionDelta, + }); + + return { + ...comp, + position: { + ...comp.position, + x: comp.position.x + positionDelta.x, + y: comp.position.y + positionDelta.y, + }, + }; + } + } + return comp; + } const newComp = { ...comp }; let current: any = newComp; @@ -1839,10 +1892,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 다중 선택된 컴포넌트들 확인 const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); - const componentsToMove = isDraggedComponentSelected + let componentsToMove = isDraggedComponentSelected ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) : [component]; + // 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동 + if (component.type === "layout") { + const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId); + + console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", { + layoutId: component.id, + zoneComponentsCount: zoneComponents.length, + zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })), + }); + + // 중복 제거하여 추가 + const allComponentIds = new Set(componentsToMove.map((c) => c.id)); + const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id)); + componentsToMove = [...componentsToMove, ...additionalComponents]; + } + console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length); console.log("마우스 위치:", { clientX: event.clientX, @@ -3035,6 +3104,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onClick={(e) => handleComponentClick(component, e)} onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} + selectedScreen={selectedScreen} > {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 */} {(component.type === "group" || component.type === "container" || component.type === "area") && @@ -3111,6 +3181,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onClick={(e) => handleComponentClick(child, e)} onDragStart={(e) => startComponentDrag(child, e)} onDragEnd={endDrag} + selectedScreen={selectedScreen} /> ); })} diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 3a390785..31ad4051 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -5,7 +5,14 @@ import { Settings } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent"; -import { ComponentData, WidgetComponent, FileComponent, WebTypeConfig, TableInfo } from "@/types/screen"; +import { + ComponentData, + WidgetComponent, + FileComponent, + WebTypeConfig, + TableInfo, + LayoutComponent, +} from "@/types/screen"; import { ButtonConfigPanel } from "./ButtonConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; @@ -41,6 +48,641 @@ export const DetailSettingsPanel: React.FC = ({ console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType); const inputableWebTypes = webTypes.map((wt) => wt.web_type); + // 레이아웃 컴포넌트 설정 렌더링 함수 + const renderLayoutConfig = (layoutComponent: LayoutComponent) => { + return ( +
+ {/* 헤더 */} +
+
+ +

레이아웃 설정

+
+
+ 타입: + + {layoutComponent.layoutType} + +
+
ID: {layoutComponent.id}
+
+ + {/* 레이아웃 설정 영역 */} +
+ {/* 기본 정보 */} +
+ + onUpdateProperty(layoutComponent.id, "label", e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500" + placeholder="레이아웃 이름을 입력하세요" + /> +
+ + {/* 그리드 레이아웃 설정 */} + {layoutComponent.layoutType === "grid" && ( +
+

그리드 설정

+
+
+ + { + const newRows = parseInt(e.target.value); + const newCols = layoutComponent.layoutConfig?.grid?.columns || 2; + + // 그리드 설정 업데이트 + onUpdateProperty(layoutComponent.id, "layoutConfig.grid.rows", newRows); + + // 존 개수 자동 업데이트 (행 × 열) + const totalZones = newRows * newCols; + const currentZones = layoutComponent.zones || []; + + if (totalZones !== currentZones.length) { + const newZones = []; + for (let row = 0; row < newRows; row++) { + for (let col = 0; col < newCols; col++) { + const zoneIndex = row * newCols + col; + newZones.push({ + id: `zone${zoneIndex + 1}`, + name: `존 ${zoneIndex + 1}`, + position: { row, column: col }, + size: { width: "100%", height: "100%" }, + }); + } + } + onUpdateProperty(layoutComponent.id, "zones", newZones); + } + }} + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+
+ + { + const newCols = parseInt(e.target.value); + const newRows = layoutComponent.layoutConfig?.grid?.rows || 2; + + // 그리드 설정 업데이트 + onUpdateProperty(layoutComponent.id, "layoutConfig.grid.columns", newCols); + + // 존 개수 자동 업데이트 (행 × 열) + const totalZones = newRows * newCols; + const currentZones = layoutComponent.zones || []; + + if (totalZones !== currentZones.length) { + const newZones = []; + for (let row = 0; row < newRows; row++) { + for (let col = 0; col < newCols; col++) { + const zoneIndex = row * newCols + col; + newZones.push({ + id: `zone${zoneIndex + 1}`, + name: `존 ${zoneIndex + 1}`, + position: { row, column: col }, + size: { width: "100%", height: "100%" }, + }); + } + } + onUpdateProperty(layoutComponent.id, "zones", newZones); + } + }} + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+
+
+ + + onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value)) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+
+ )} + + {/* 플렉스박스 레이아웃 설정 */} + {layoutComponent.layoutType === "flexbox" && ( +
+

플렉스박스 설정

+
+ + +
+
+ +
+ { + const newZoneCount = parseInt(e.target.value); + const currentZones = layoutComponent.zones || []; + + const direction = layoutComponent.layoutConfig?.flexbox?.direction || "row"; + + if (newZoneCount > currentZones.length) { + // 존 추가 + const newZones = [...currentZones]; + for (let i = currentZones.length; i < newZoneCount; i++) { + newZones.push({ + id: `zone${i + 1}`, + name: `존 ${i + 1}`, + position: {}, + size: { + width: direction === "row" ? `${100 / newZoneCount}%` : "100%", + height: direction === "column" ? `${100 / newZoneCount}%` : "100%", + }, + }); + } + // 기존 존들의 크기도 조정 + newZones.forEach((zone, index) => { + if (direction === "row") { + zone.size.width = `${100 / newZoneCount}%`; + } else { + zone.size.height = `${100 / newZoneCount}%`; + } + }); + onUpdateProperty(layoutComponent.id, "zones", newZones); + } else if (newZoneCount < currentZones.length) { + // 존 제거 + const newZones = currentZones.slice(0, newZoneCount); + // 남은 존들의 크기 재조정 + newZones.forEach((zone, index) => { + if (direction === "row") { + zone.size.width = `${100 / newZoneCount}%`; + } else { + zone.size.height = `${100 / newZoneCount}%`; + } + }); + onUpdateProperty(layoutComponent.id, "zones", newZones); + } + }} + className="w-20 rounded border border-gray-300 px-2 py-1 text-sm" + /> + +
+
+
+ + + onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value)) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+
+ )} + + {/* 분할 레이아웃 설정 */} + {layoutComponent.layoutType === "split" && ( +
+

분할 설정

+
+ + +
+
+ )} + + {/* 카드 레이아웃 설정 */} + {layoutComponent.layoutType === "card-layout" && ( +
+

카드 설정

+ + {/* 테이블 컬럼 매핑 */} +
+
+
테이블 컬럼 매핑
+ {currentTable && ( + + 테이블: {currentTable.table_name} + + )} +
+ + {/* 테이블이 선택되지 않은 경우 안내 */} + {!currentTable && ( +
+

테이블을 먼저 선택해주세요

+

+ 화면 설정에서 테이블을 선택하면 컬럼 목록이 표시됩니다 +

+
+ )} + + {/* 테이블이 선택된 경우 컬럼 드롭다운 */} + {currentTable && ( + <> +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {/* 동적 표시 컬럼 추가 */} +
+
+ + +
+ +
+ {(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []).map( + (column, index) => ( +
+ + +
+ ), + )} + + {(!layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || + layoutComponent.layoutConfig.card.columnMapping.displayColumns.length === 0) && ( +
+ "컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요 +
+ )} +
+
+ + )} +
+ + {/* 카드 스타일 설정 */} +
+
카드 스타일
+ +
+
+ + + onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value)) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+ +
+ + + onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value)) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+
+ +
+
+ + onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardStyle.showTitle", e.target.checked) + } + className="rounded border-gray-300" + /> + +
+ +
+ + onUpdateProperty( + layoutComponent.id, + "layoutConfig.card.cardStyle.showSubtitle", + e.target.checked, + ) + } + className="rounded border-gray-300" + /> + +
+ +
+ + onUpdateProperty( + layoutComponent.id, + "layoutConfig.card.cardStyle.showDescription", + e.target.checked, + ) + } + className="rounded border-gray-300" + /> + +
+ +
+ + onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardStyle.showImage", e.target.checked) + } + className="rounded border-gray-300" + /> + +
+
+ +
+ + + onUpdateProperty( + layoutComponent.id, + "layoutConfig.card.cardStyle.maxDescriptionLength", + parseInt(e.target.value), + ) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> +
+
+
+ )} + + {/* 존 목록 - 카드 레이아웃은 데이터 기반이므로 존 관리 불필요 */} + {layoutComponent.layoutType !== "card-layout" && ( +
+

존 목록

+
+ {layoutComponent.zones?.map((zone, index) => ( +
+
+ {zone.name} + ID: {zone.id} +
+
+
+ + + onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-xs" + placeholder="100%" + /> +
+
+ + + onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value) + } + className="w-full rounded border border-gray-300 px-2 py-1 text-xs" + placeholder="auto" + /> +
+
+
+ ))} +
+
+ )} +
+
+ ); + }; + // 웹타입별 상세 설정 렌더링 함수 - useCallback 제거하여 항상 최신 widget 사용 const renderWebTypeConfig = (widget: WidgetComponent) => { const currentConfig = widget.webTypeConfig || {}; @@ -231,13 +873,18 @@ export const DetailSettingsPanel: React.FC = ({ ); } + // 레이아웃 컴포넌트 처리 + if (selectedComponent.type === "layout") { + return renderLayoutConfig(selectedComponent as LayoutComponent); + } + if (selectedComponent.type !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") { return (

설정할 수 없는 컴포넌트입니다

- 상세 설정은 위젯, 파일, 버튼 컴포넌트에서만 사용할 수 있습니다. + 상세 설정은 위젯, 파일, 버튼, 레이아웃 컴포넌트에서만 사용할 수 있습니다.
현재 선택된 컴포넌트: {selectedComponent.type}

diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 0aa4bf5f..fe26b32f 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -570,39 +570,49 @@ const PropertiesPanelComponent: React.FC = ({ />
-
- - { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, width: newValue })); - onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) }); - }} - className="mt-1" - /> -
+ {/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */} + {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? ( + <> +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, width: newValue })); + onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) }); + }} + className="mt-1" + /> +
-
- - { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, height: newValue })); - onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) }); - }} - className="mt-1" - /> -
+
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, height: newValue })); + onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) }); + }} + className="mt-1" + /> +
+ + ) : ( +
+

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

+

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

+
+ )}