diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 68dbdf18..80ebfe31 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -376,6 +376,10 @@ export class ScreenManagementService { layoutData: LayoutData, companyCode: string ): Promise { + console.log(`=== 레이아웃 저장 시작 ===`); + console.log(`화면 ID: ${screenId}`); + console.log(`컴포넌트 수: ${layoutData.components.length}`); + // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, @@ -398,12 +402,22 @@ export class ScreenManagementService { for (const component of layoutData.components) { const { id, ...componentData } = component; + console.log(`저장 중인 컴포넌트:`, { + id: component.id, + type: component.type, + position: component.position, + size: component.size, + parentId: component.parentId, + title: (component as any).title, + }); + // Prisma JSON 필드에 맞는 타입으로 변환 const properties: any = { ...componentData, position: { x: component.position.x, y: component.position.y, + z: component.position.z || 1, // z 값 포함 }, size: { width: component.size.width, @@ -425,6 +439,8 @@ export class ScreenManagementService { }, }); } + + console.log(`=== 레이아웃 저장 완료 ===`); } /** @@ -434,6 +450,9 @@ export class ScreenManagementService { screenId: number, companyCode: string ): Promise { + console.log(`=== 레이아웃 로드 시작 ===`); + console.log(`화면 ID: ${screenId}`); + // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, @@ -452,6 +471,8 @@ export class ScreenManagementService { orderBy: { display_order: "asc" }, }); + console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`); + if (layouts.length === 0) { return { components: [], @@ -461,16 +482,34 @@ export class ScreenManagementService { const components: ComponentData[] = layouts.map((layout) => { const properties = layout.properties as any; - return { + const component = { id: layout.component_id, type: layout.component_type as any, - position: { x: layout.position_x, y: layout.position_y }, + position: { + x: layout.position_x, + y: layout.position_y, + z: properties?.position?.z || 1, // z 값 복원 + }, size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, }; + + console.log(`로드된 컴포넌트:`, { + id: component.id, + type: component.type, + position: component.position, + size: component.size, + parentId: component.parentId, + title: (component as any).title, + }); + + return component; }); + console.log(`=== 레이아웃 로드 완료 ===`); + console.log(`반환할 컴포넌트 수: ${components.length}`); + return { components, gridSettings: { columns: 12, gap: 16, padding: 16 }, diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index b44bc7fb..8c76b7c4 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -20,6 +20,7 @@ export type WebType = export interface Position { x: number; y: number; + z?: number; // z-index (레이어 순서) } // 크기 정보 diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 38eb8622..7963e380 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -171,32 +171,90 @@ export default function ScreenViewPage() { {/* 실제 화면 렌더링 영역 */}
{layout.components - .filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => ( -
- { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + .filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함) + .map((component) => { + // 그룹 컴포넌트인 경우 특별 처리 + if (component.type === "group") { + const groupChildren = layout.components.filter((child) => child.parentId === component.id); + + return ( +
+ {/* 그룹 제목 */} + {(component as any).title && ( +
{(component as any).title}
+ )} + + {/* 그룹 내 자식 컴포넌트들 렌더링 */} + {groupChildren.map((child) => ( +
+ { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + /> +
+ ))} +
+ ); + } + + // 일반 컴포넌트 렌더링 + return ( +
-
- ))} + > + { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + /> +
+ ); + })}
diff --git a/frontend/components/screen/GroupingToolbar.tsx b/frontend/components/screen/GroupingToolbar.tsx index 642928a8..5a8160a0 100644 --- a/frontend/components/screen/GroupingToolbar.tsx +++ b/frontend/components/screen/GroupingToolbar.tsx @@ -39,6 +39,8 @@ interface GroupingToolbarProps { allComponents: ComponentData[]; onGroupAlign?: (mode: "left" | "centerX" | "right" | "top" | "centerY" | "bottom") => void; onGroupDistribute?: (orientation: "horizontal" | "vertical") => void; + showCreateDialog?: boolean; + onShowCreateDialogChange?: (show: boolean) => void; } export const GroupingToolbar: React.FC = ({ @@ -50,8 +52,12 @@ export const GroupingToolbar: React.FC = ({ allComponents, onGroupAlign, onGroupDistribute, + showCreateDialog: externalShowCreateDialog, + onShowCreateDialogChange, }) => { - const [showCreateDialog, setShowCreateDialog] = useState(false); + const [internalShowCreateDialog, setInternalShowCreateDialog] = useState(false); + const showCreateDialog = externalShowCreateDialog ?? internalShowCreateDialog; + const setShowCreateDialog = onShowCreateDialogChange ?? setInternalShowCreateDialog; const [groupTitle, setGroupTitle] = useState("새 그룹"); const [groupStyle, setGroupStyle] = useState(createGroupStyle()); @@ -131,10 +137,13 @@ export const GroupingToolbar: React.FC = ({ size="sm" onClick={handleCreateGroup} disabled={!canCreateGroup} - title={canCreateGroup ? "선택된 컴포넌트를 그룹으로 묶기" : "2개 이상의 컴포넌트를 선택하세요"} + title={canCreateGroup ? "선택된 컴포넌트를 그룹으로 묶기 (Ctrl+G)" : "2개 이상의 컴포넌트를 선택하세요"} > 그룹 생성 + + Ctrl+G + {/* 그룹 해제 버튼 */} @@ -143,10 +152,13 @@ export const GroupingToolbar: React.FC = ({ size="sm" onClick={handleUngroup} disabled={!selectedGroup} - title={selectedGroup ? "선택된 그룹 해제" : "그룹을 선택하세요"} + title={selectedGroup ? "선택된 그룹 해제 (Ctrl+Shift+G)" : "그룹을 선택하세요"} > 그룹 해제 + + Ctrl+⇧+G + {/* 선택 해제 버튼 */} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index b292aa6f..5508e9dd 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -140,6 +140,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD groupMode: "create", }); + // 그룹 생성 다이얼로그 상태 + const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + const [tables, setTables] = useState([]); const [expandedTables, setExpandedTables] = useState>(new Set()); @@ -766,6 +769,57 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD pasteComponents(); } break; + case "g": + case "G": + e.preventDefault(); + if (e.shiftKey) { + // Ctrl+Shift+G: 그룹 해제 + const selectedComponents = layout.components.filter((comp) => + groupState.selectedComponents.includes(comp.id), + ); + if (selectedComponents.length === 1 && selectedComponents[0].type === "group") { + // 그룹 해제 로직을 직접 실행 + const group = selectedComponents[0] as any; + const groupChildren = layout.components.filter((comp) => comp.parentId === group.id); + + // 자식 컴포넌트들의 절대 위치 복원 + const absoluteChildren = groupChildren.map((child) => ({ + ...child, + position: { + ...child.position, + x: child.position.x + group.position.x, + y: child.position.y + group.position.y, + z: child.position.z || 1, + }, + parentId: undefined, + })); + + const newLayout = { + ...layout, + components: [ + ...layout.components.filter((comp) => comp.id !== group.id && comp.parentId !== group.id), + ...absoluteChildren, + ], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [], + isGrouping: false, + })); + } + } else { + // Ctrl+G: 그룹 생성 다이얼로그 열기 + const selectedComponents = layout.components.filter((comp) => + groupState.selectedComponents.includes(comp.id), + ); + if (selectedComponents.length >= 2) { + setShowGroupCreateDialog(true); + } + } + break; } } else if (e.key === "Delete") { e.preventDefault(); @@ -776,7 +830,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [undo, redo, copyComponents, pasteComponents, deleteComponents, clipboard]); + }, [ + undo, + redo, + copyComponents, + pasteComponents, + deleteComponents, + clipboard, + layout, + groupState, + saveToHistory, + setLayout, + setGroupState, + setShowGroupCreateDialog, + ]); // 컴포넌트 속성 업데이트 함수 const updateComponentProperty = useCallback( @@ -1184,14 +1251,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const handleComponentClick = useCallback( (component: ComponentData, event?: React.MouseEvent) => { const isShiftPressed = event?.shiftKey || false; - - // 그룹 컨테이너는 다중선택 대상에서 제외 const isGroupContainer = component.type === "group"; if (groupState.isGrouping || isShiftPressed) { // 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택 if (isGroupContainer) { - // 그룹 컨테이너 클릭은 다중선택에 포함하지 않고 무시 + // 그룹 컨테이너는 다중선택에서 제외하고 단일 선택으로 처리 + setSelectedComponent(component); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [component.id], + isGrouping: false, // 그룹 선택 시 그룹화 모드 해제 + })); return; } const isSelected = groupState.selectedComponents.includes(component.id); @@ -1211,7 +1282,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setSelectedComponent(component); setGroupState((prev) => ({ ...prev, - selectedComponents: isGroupContainer ? [] : [component.id], + selectedComponents: [component.id], // 그룹도 선택 가능하도록 수정 })); } }, @@ -1392,6 +1463,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setLayout(newLayout); saveToHistory(newLayout); }} + showCreateDialog={showGroupCreateDialog} + onShowCreateDialogChange={setShowGroupCreateDialog} /> {/* 메인 컨텐츠 영역 */} diff --git a/frontend/lib/utils/groupingUtils.ts b/frontend/lib/utils/groupingUtils.ts index adb62c3d..eb81c5ab 100644 --- a/frontend/lib/utils/groupingUtils.ts +++ b/frontend/lib/utils/groupingUtils.ts @@ -22,9 +22,9 @@ export function createGroupComponent( return { id: generateComponentId(), type: "group", - position, + position: { ...position, z: position.z || 1 }, // z 값 포함 size: { width: groupWidth, height: groupHeight }, - label: title, // title 대신 label 사용 + title: title, // GroupComponent 타입에 맞게 title 사용 backgroundColor: "#f8f9fa", border: "1px solid #dee2e6", borderRadius: 8, @@ -78,6 +78,7 @@ export function calculateRelativePositions( position: { x: component.position.x - groupPosition.x, y: component.position.y - groupPosition.y, + z: component.position.z || 1, // z 값 유지 }, parentId: groupId, // 그룹 ID를 부모로 설정 })); @@ -90,6 +91,7 @@ export function restoreAbsolutePositions(components: ComponentData[], groupPosit position: { x: component.position.x + groupPosition.x, y: component.position.y + groupPosition.y, + z: component.position.z || 1, // z 값 유지 }, parentId: undefined, })); diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index 0949224b..bc831e16 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -27,6 +27,7 @@ export type WebType = export interface Position { x: number; y: number; + z?: number; // z-index (레이어 순서) } // 크기 정보