diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 465570ce..7a7fa11e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -33,7 +33,17 @@ app.use( origin: config.cors.origin, // 이미 배열 또는 boolean으로 처리됨 credentials: config.cors.credentials, 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, }) ); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 3b424dac..b048d498 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -129,9 +129,57 @@ export class DynamicFormService { dataToInsert.updated_by = updated_by; } if (company_code && tableColumns.includes("company_code")) { - dataToInsert.company_code = company_code; + // company_code가 UUID 형태(36자)라면 하이픈 제거하여 32자로 만듦 + let processedCompanyCode = company_code; + if ( + typeof company_code === "string" && + company_code.length === 36 && + company_code.includes("-") + ) { + processedCompanyCode = company_code.replace(/-/g, ""); + console.log( + `🔧 company_code 길이 조정: "${company_code}" -> "${processedCompanyCode}" (${processedCompanyCode.length}자)` + ); + } + // 여전히 32자를 초과하면 앞의 32자만 사용 + if ( + typeof processedCompanyCode === "string" && + processedCompanyCode.length > 32 + ) { + processedCompanyCode = processedCompanyCode.substring(0, 32); + console.log( + `⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"` + ); + } + dataToInsert.company_code = processedCompanyCode; } + // 날짜/시간 문자열을 적절한 형태로 변환 + Object.keys(dataToInsert).forEach((key) => { + const value = dataToInsert[key]; + + // 날짜/시간 관련 컬럼명 패턴 체크 (regdate, created_at, updated_at 등) + if ( + typeof value === "string" && + (key.toLowerCase().includes("date") || + key.toLowerCase().includes("time") || + key.toLowerCase().includes("created") || + key.toLowerCase().includes("updated") || + key.toLowerCase().includes("reg")) + ) { + // YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환 + if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) { + console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`); + dataToInsert[key] = new Date(value); + } + // YYYY-MM-DD 형태의 문자열을 Date 객체로 변환 + else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) { + console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`); + dataToInsert[key] = new Date(value + "T00:00:00"); + } + } + }); + // 존재하지 않는 컬럼 제거 Object.keys(dataToInsert).forEach((key) => { if (!tableColumns.includes(key)) { diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index c9049da5..76b5da12 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -45,12 +45,22 @@ export class ScreenManagementService { screenData: CreateScreenRequest, userCompanyCode: string ): Promise { + console.log(`=== 화면 생성 요청 ===`); + console.log(`요청 데이터:`, screenData); + console.log(`사용자 회사 코드:`, userCompanyCode); + // 화면 코드 중복 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_code: screenData.screenCode }, }); + console.log( + `화면 코드 '${screenData.screenCode}' 중복 검사 결과:`, + existingScreen ? "중복됨" : "사용 가능" + ); + if (existingScreen) { + console.log(`기존 화면 정보:`, existingScreen); throw new Error("이미 존재하는 화면 코드입니다."); } @@ -437,6 +447,8 @@ export class ScreenManagementService { console.log(`=== 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}`); console.log(`컴포넌트 수: ${layoutData.components.length}`); + console.log(`격자 설정:`, layoutData.gridSettings); + console.log(`해상도 설정:`, layoutData.screenResolution); // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ @@ -451,12 +463,37 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } - // 기존 레이아웃 삭제 + // 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두) await prisma.screen_layouts.deleteMany({ where: { screen_id: screenId }, }); - // 새 레이아웃 저장 + // 1. 메타데이터 저장 (격자 설정과 해상도 정보) + if (layoutData.gridSettings || layoutData.screenResolution) { + const metadata: any = { + gridSettings: layoutData.gridSettings, + screenResolution: layoutData.screenResolution, + }; + + await prisma.screen_layouts.create({ + data: { + screen_id: screenId, + component_type: "_metadata", // 특별한 타입으로 메타데이터 식별 + component_id: `_metadata_${screenId}`, + parent_id: null, + position_x: 0, + position_y: 0, + width: 0, + height: 0, + properties: metadata, + display_order: -1, // 메타데이터는 맨 앞에 배치 + }, + }); + + console.log(`메타데이터 저장 완료:`, metadata); + } + + // 2. 컴포넌트 저장 for (const component of layoutData.components) { const { id, ...componentData } = component; @@ -531,14 +568,45 @@ export class ScreenManagementService { console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`); - if (layouts.length === 0) { + // 메타데이터와 컴포넌트 분리 + const metadataLayout = layouts.find( + (layout) => layout.component_type === "_metadata" + ); + const componentLayouts = layouts.filter( + (layout) => layout.component_type !== "_metadata" + ); + + // 기본 메타데이터 설정 + let gridSettings = { + columns: 12, + gap: 16, + padding: 16, + snapToGrid: true, + showGrid: true, + }; + let screenResolution = null; + + // 저장된 메타데이터가 있으면 적용 + if (metadataLayout && metadataLayout.properties) { + const metadata = metadataLayout.properties as any; + if (metadata.gridSettings) { + gridSettings = { ...gridSettings, ...metadata.gridSettings }; + } + if (metadata.screenResolution) { + screenResolution = metadata.screenResolution; + } + console.log(`메타데이터 로드:`, { gridSettings, screenResolution }); + } + + if (componentLayouts.length === 0) { return { components: [], - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings, + screenResolution, }; } - const components: ComponentData[] = layouts.map((layout) => { + const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; const component = { id: layout.component_id, @@ -567,10 +635,13 @@ export class ScreenManagementService { console.log(`=== 레이아웃 로드 완료 ===`); console.log(`반환할 컴포넌트 수: ${components.length}`); + console.log(`최종 격자 설정:`, gridSettings); + console.log(`최종 해상도 설정:`, screenResolution); return { components, - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings, + screenResolution, }; } diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index 9cb63cea..9b9a55bf 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -108,6 +108,7 @@ export type ComponentData = export interface LayoutData { components: ComponentData[]; gridSettings?: GridSettings; + screenResolution?: ScreenResolution; } // 그리드 설정 @@ -115,6 +116,18 @@ export interface GridSettings { columns: number; // 기본값: 12 gap: number; // 기본값: 16px padding: number; // 기본값: 16px + snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true) + showGrid?: boolean; // 격자 표시 여부 (기본값: true) + gridColor?: string; // 격자 색상 (기본값: #d1d5db) + gridOpacity?: number; // 격자 투명도 (기본값: 0.5) +} + +// 화면 해상도 설정 +export interface ScreenResolution { + width: number; + height: number; + name: string; + category: "desktop" | "tablet" | "mobile" | "custom"; } // 유효성 검증 규칙 diff --git a/docs/화면관리_시스템_설계.md b/docs/화면관리_시스템_설계.md index 34fa3ccc..9003f341 100644 --- a/docs/화면관리_시스템_설계.md +++ b/docs/화면관리_시스템_설계.md @@ -42,6 +42,7 @@ - **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file - **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징 - **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅 +- **🆕 화면 저장 후 메뉴 할당**: 저장 완료 시 자동 메뉴 할당 모달, 기존 화면 교체 확인, 시각적 피드백 및 자동 목록 복귀 #### 🔧 해결된 기술적 문제들 @@ -410,6 +411,9 @@ const removeItem = useCallback( - **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당 - **메뉴-화면 연결**: 메뉴와 화면의 1:1 또는 1:N 연결 - **권한 기반 메뉴 표시**: 사용자 권한에 따른 메뉴 표시 제어 +- **🆕 저장 후 자동 할당**: 화면 저장 완료 시 메뉴 할당 모달 자동 팝업 +- **🆕 기존 화면 교체**: 이미 할당된 화면이 있을 때 교체 확인 및 안전한 처리 +- **🆕 완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름 ## 🗄️ 데이터베이스 설계 @@ -1172,9 +1176,264 @@ function generateValidationRules(column: ColumnInfo): ValidationRule[] { } ``` +## 🎯 메뉴 할당 시스템 (신규 완성) + +### 1. 화면 저장 후 메뉴 할당 워크플로우 + +#### 전체 프로세스 + +``` +화면 설계 완료 → 저장 버튼 클릭 → 메뉴 할당 모달 자동 팝업 + ↓ +메뉴 선택 및 할당 OR "나중에 할당" 클릭 + ↓ +성공 화면 표시 (3초간 시각적 피드백) + ↓ +자동으로 화면 목록 페이지로 이동 +``` + +#### 메뉴 할당 모달 (MenuAssignmentModal) + +**주요 기능:** + +1. **관리자 메뉴만 표시**: 화면관리는 관리자 전용 기능이므로 관리자 메뉴(`menuType: "0"`)만 로드 +2. **셀렉트박스 내부 검색**: 메뉴명, URL, 설명으로 실시간 검색 가능 +3. **기존 화면 감지**: 선택한 메뉴에 이미 할당된 화면이 있는지 자동 확인 +4. **화면 교체 확인**: 기존 화면이 있을 때 교체 확인 대화상자 표시 + +```typescript +interface MenuAssignmentModalProps { + isOpen: boolean; + onClose: () => void; + screenInfo: ScreenDefinition | null; + onAssignmentComplete?: () => void; + onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백 +} +``` + +### 2. 메뉴 검색 시스템 + +```typescript +// 셀렉트박스 내부 검색 구현 + + {/* 검색 입력 필드 */} +
+
+ + { + e.stopPropagation(); // 이벤트 전파 방지 + setSearchTerm(e.target.value); + }} + onKeyDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="h-8 pr-8 pl-10 text-sm" + /> + {searchTerm && ( + + )} +
+
+ {/* 메뉴 옵션들 */} +
{getMenuOptions()}
+
+``` + +### 3. 기존 화면 감지 및 교체 시스템 + +```typescript +// 메뉴 선택 시 기존 할당된 화면 확인 +const handleMenuSelect = async (menuId: string) => { + const menu = menus.find((m) => m.objid?.toString() === menuId); + setSelectedMenu(menu || null); + + if (menu) { + try { + const menuObjid = parseInt(menu.objid?.toString() || "0"); + const screens = await menuScreenApi.getScreensByMenu(menuObjid); + setExistingScreens(screens); + } catch (error) { + console.error("할당된 화면 조회 실패:", error); + } + } +}; + +// 할당 시 기존 화면 확인 +const handleAssignScreen = async () => { + if (existingScreens.length > 0) { + // 이미 같은 화면이 할당되어 있는지 확인 + const alreadyAssigned = existingScreens.some( + (screen) => screen.screenId === screenInfo.screenId + ); + if (alreadyAssigned) { + toast.info("이미 해당 메뉴에 할당된 화면입니다."); + return; + } + + // 다른 화면이 할당되어 있으면 교체 확인 + setShowReplaceDialog(true); + return; + } + + // 기존 화면이 없으면 바로 할당 + await performAssignment(); +}; +``` + +### 4. 화면 교체 확인 대화상자 + +**시각적 구분:** + +- 🔴 **제거될 화면**: 빨간색 배경으로 표시 +- 🟢 **새로 할당될 화면**: 초록색 배경으로 표시 +- 🟠 **주의 메시지**: 작업이 되돌릴 수 없음을 명확히 안내 + +**안전한 교체 프로세스:** + +1. 기존 화면들을 하나씩 제거 +2. 새 화면 할당 +3. 성공/실패 로그 출력 + +```typescript +// 기존 화면 교체인 경우 기존 화면들 먼저 제거 +if (replaceExisting && existingScreens.length > 0) { + for (const existingScreen of existingScreens) { + try { + await menuScreenApi.unassignScreenFromMenu( + existingScreen.screenId, + menuObjid + ); + console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`); + } catch (error) { + console.error( + `기존 화면 "${existingScreen.screenName}" 제거 실패:`, + error + ); + } + } +} + +// 새 화면 할당 +await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid); +``` + +### 5. 성공 피드백 및 자동 이동 + +**성공 화면 구성:** + +- ✅ **체크마크 아이콘**: 성공을 나타내는 녹색 체크마크 +- 🎯 **성공 메시지**: 구체적인 할당 완료 메시지 +- ⏱️ **자동 이동 안내**: "3초 후 자동으로 화면 목록으로 이동합니다..." +- 🔵 **로딩 애니메이션**: 3개의 점이 순차적으로 바운스하는 애니메이션 + +```typescript +// 성공 상태 설정 +setAssignmentSuccess(true); +setAssignmentMessage(successMessage); + +// 3초 후 자동으로 화면 목록으로 이동 +setTimeout(() => { + if (onBackToList) { + onBackToList(); + } else { + onClose(); + } +}, 3000); +``` + +**성공 화면 UI:** + +```jsx +{assignmentSuccess ? ( + // 성공 화면 + <> + + +
+ + + +
+ 화면 할당 완료 +
+
+ +
+
+
+
+ +
+
+

{assignmentMessage}

+

+ 3초 후 자동으로 화면 목록으로 이동합니다... +

+
+
+
+ + {/* 로딩 애니메이션 */} +
+
+
+
+
+
+ +) : ( + // 기본 할당 화면 + // ... +)} +``` + +### 6. 사용자 경험 개선사항 + +1. **선택적 할당**: 필수가 아닌 선택적 기능으로 "나중에 할당" 가능 +2. **직관적 UI**: 저장된 화면 정보를 모달에서 바로 확인 가능 +3. **검색 기능**: 많은 메뉴 중에서 쉽게 찾을 수 있음 +4. **상태 표시**: 메뉴 활성/비활성 상태, 기존 할당된 화면 정보 표시 +5. **완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름 + ## 🌐 API 설계 -### 1. 화면 정의 API +### 1. 메뉴-화면 할당 API + +#### 화면을 메뉴에 할당 + +```typescript +POST /screen-management/screens/:screenId/assign-menu +Request: { + menuObjid: number; + displayOrder?: number; +} +``` + +#### 메뉴별 할당된 화면 목록 조회 + +```typescript +GET /screen-management/menus/:menuObjid/screens +Response: { + success: boolean; + data: ScreenDefinition[]; +} +``` + +#### 화면-메뉴 할당 해제 + +```typescript +DELETE /screen-management/screens/:screenId/menus/:menuObjid +Response: { + success: boolean; + message: string; +} +``` + +### 2. 화면 정의 API #### 화면 목록 조회 (회사별) @@ -2675,7 +2934,19 @@ export class TableTypeIntegrationService { 3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정 4. **저장**: 커스터마이징된 화면 저장 -### 6. 메뉴 할당 및 관리 +### 6. 메뉴 할당 및 관리 (신규 완성) + +#### 🆕 저장 후 자동 메뉴 할당 + +1. **화면 저장 완료**: 화면 설계 완료 후 저장 버튼 클릭 +2. **메뉴 할당 모달 자동 팝업**: 저장 성공 시 즉시 메뉴 할당 모달 표시 +3. **관리자 메뉴 검색**: 메뉴명, URL, 설명으로 실시간 검색 +4. **기존 화면 확인**: 선택한 메뉴에 이미 할당된 화면 자동 감지 +5. **교체 확인**: 기존 화면이 있을 때 교체 여부 확인 대화상자 +6. **안전한 교체**: 기존 화면 제거 후 새 화면 할당 +7. **성공 피드백**: 3초간 성공 화면 표시 후 자동으로 화면 목록으로 이동 + +#### 기존 메뉴 할당 방식 1. **메뉴 선택**: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시) 2. **화면 할당**: 선택한 화면을 메뉴에 할당 @@ -2778,6 +3049,7 @@ export class TableTypeIntegrationService { - [x] 메뉴-화면 할당 기능 구현 - [x] 인터랙티브 화면 뷰어 구현 - [x] 사용자 피드백 반영 완료 +- [x] 🆕 화면 저장 후 메뉴 할당 워크플로우 구현 **구현 완료 사항:** @@ -2788,6 +3060,7 @@ export class TableTypeIntegrationService { - 메뉴 관리에서 화면 할당 기능 구현 - 할당된 화면을 실제 사용 가능한 인터랙티브 화면으로 렌더링 - 실제 사용자 입력 및 상호작용 가능한 완전 기능 화면 구현 +- 🆕 **완전한 메뉴 할당 워크플로우**: 저장 → 메뉴 할당 모달 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀 ## 🎯 현재 구현된 핵심 기능 @@ -3559,3 +3832,4 @@ ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로 - ✅ **메뉴 연동**: 설계한 화면을 실제 메뉴에 할당하여 즉시 사용 - ✅ **인터랙티브 화면**: 할당된 화면에서 실제 사용자 입력 및 상호작용 가능 - ✅ **13가지 웹 타입 지원**: 모든 업무 요구사항에 대응 가능한 다양한 위젯 +- ✅ **🆕 완전한 메뉴 할당 워크플로우**: 저장 → 자동 메뉴 할당 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀의 완벽한 사용자 경험 diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index d5d2f8ba..f391b63b 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -58,7 +58,7 @@ export default function ScreenViewPage() { if (loading) { return ( -
+

화면을 불러오는 중...

@@ -69,7 +69,7 @@ export default function ScreenViewPage() { if (error || !screen) { return ( -
+
⚠️ @@ -84,11 +84,23 @@ export default function ScreenViewPage() { ); } + // 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용 + const screenWidth = layout?.screenResolution?.width || 1200; + const screenHeight = layout?.screenResolution?.height || 800; + return ( -
+
{layout && layout.components.length > 0 ? ( - // 캔버스 컴포넌트들만 표시 - 전체 화면 사용 -
+ // 캔버스 컴포넌트들을 정확한 해상도로 표시 +
{layout.components .filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함) .map((component) => { @@ -218,7 +230,15 @@ export default function ScreenViewPage() {
) : ( // 빈 화면일 때도 깔끔하게 표시 -
+
📄 diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 2d65082f..fdb56ed5 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter, usePathname } from "next/navigation"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Shield, @@ -194,6 +194,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten export function AppLayout({ children }: AppLayoutProps) { const router = useRouter(); const pathname = usePathname(); + const searchParams = useSearchParams(); const { user, logout, refreshUserData } = useAuth(); const { userMenus, adminMenus, loading, refreshMenus } = useMenu(); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -216,8 +217,8 @@ export function AppLayout({ children }: AppLayoutProps) { saveProfile, } = useProfile(user, refreshUserData, refreshMenus); - // 현재 경로에 따라 어드민 모드인지 판단 - const isAdminMode = pathname.startsWith("/admin"); + // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) + const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin"; // 현재 모드에 따라 표시할 메뉴 결정 const currentMenus = isAdminMode ? adminMenus : userMenus; @@ -246,7 +247,20 @@ export function AppLayout({ children }: AppLayoutProps) { if (assignedScreens.length > 0) { // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - router.push(`/screens/${firstScreen.screenId}`); + + // 관리자 모드 상태를 쿼리 파라미터로 전달 + const screenPath = isAdminMode + ? `/screens/${firstScreen.screenId}?mode=admin` + : `/screens/${firstScreen.screenId}`; + + console.log("🎯 메뉴에서 화면으로 이동:", { + menuName: menu.name, + screenId: firstScreen.screenId, + isAdminMode, + targetPath: screenPath, + }); + + router.push(screenPath); setSidebarOpen(false); return; } 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..de841376 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; @@ -8,9 +8,11 @@ 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"; +import { useAuth } from "@/hooks/useAuth"; import { ComponentData, WidgetComponent, @@ -30,6 +32,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; @@ -51,26 +54,173 @@ export const InteractiveScreenViewer: React.FC = ( hideLabel = false, screenInfo, }) => { + const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기 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); + const [popupScreenResolution, setPopupScreenResolution] = useState<{ width: number; height: number } | null>(null); + const [popupScreenInfo, setPopupScreenInfo] = useState<{ id: number; tableName?: string } | null>(null); + + // 팝업 전용 formData 상태 + const [popupFormData, setPopupFormData] = useState>({}); - // 실제 사용할 폼 데이터 (외부에서 제공된 경우 우선 사용) - const formData = externalFormData || localFormData; + // 자동값 생성 함수 + const generateAutoValue = useCallback((autoValueType: string): string => { + const now = new Date(); + switch (autoValueType) { + case "current_datetime": + return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss + case "current_date": + return now.toISOString().slice(0, 10); // YYYY-MM-DD + case "current_time": + return now.toTimeString().slice(0, 8); // HH:mm:ss + case "current_user": + // 실제 접속중인 사용자명 사용 + return userName || "사용자"; // 사용자명이 없으면 기본값 + case "uuid": + return crypto.randomUUID(); + case "sequence": + return `SEQ_${Date.now()}`; + default: + return ""; + } + }, [userName]); // userName 의존성 추가 + + // 팝업 화면 레이아웃 로드 + React.useEffect(() => { + if (popupScreen) { + const loadPopupLayout = async () => { + try { + setPopupLoading(true); + console.log("🔍 팝업 화면 로드 시작:", popupScreen); + + // 화면 레이아웃과 화면 정보를 병렬로 가져오기 + const [layout, screen] = await Promise.all([ + screenApi.getLayout(popupScreen.screenId), + screenApi.getScreen(popupScreen.screenId) + ]); + + console.log("📊 팝업 화면 로드 완료:", { + componentsCount: layout.components?.length || 0, + screenInfo: { + screenId: screen.screenId, + tableName: screen.tableName + }, + popupFormData: {} + }); + + setPopupLayout(layout.components || []); + setPopupScreenResolution(layout.screenResolution || null); + setPopupScreenInfo({ + id: popupScreen.screenId, + tableName: screen.tableName + }); + + // 팝업 formData 초기화 + setPopupFormData({}); + } catch (error) { + console.error("❌ 팝업 화면 로드 실패:", error); + setPopupLayout([]); + setPopupScreenInfo(null); + } finally { + setPopupLoading(false); + } + }; + + loadPopupLayout(); + } + }, [popupScreen]); + + // 실제 사용할 폼 데이터 (외부와 로컬 데이터 병합) + const formData = { ...localFormData, ...externalFormData }; + console.log("🔄 formData 구성:", { + external: externalFormData, + local: localFormData, + merged: formData, + hasExternalCallback: !!onFormDataChange + }); // 폼 데이터 업데이트 const updateFormData = (fieldName: string, value: any) => { + console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`); + + // 항상 로컬 상태도 업데이트 + setLocalFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`); + + // 외부 콜백이 있는 경우에도 전달 if (onFormDataChange) { - // 외부 콜백이 있는 경우 사용 - onFormDataChange(fieldName, value); - } else { - // 로컬 상태 업데이트 - setLocalFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + // 개별 필드를 객체로 변환해서 전달 + const dataToSend = { [fieldName]: value }; + onFormDataChange(dataToSend); + console.log(`📤 외부 콜백으로 전달: ${fieldName} = "${value}" (객체: ${JSON.stringify(dataToSend)})`); } }; + // 자동입력 필드들의 값을 formData에 초기 설정 + React.useEffect(() => { + console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length); + const initAutoInputFields = () => { + console.log("🔧 initAutoInputFields 실행 시작"); + allComponents.forEach(comp => { + if (comp.type === 'widget') { + const widget = comp as WidgetComponent; + const fieldName = widget.columnName || widget.id; + + // 텍스트 타입 위젯의 자동입력 처리 + if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && + widget.webTypeConfig) { + const config = widget.webTypeConfig as TextTypeConfig; + const isAutoInput = config?.autoInput || false; + + if (isAutoInput && config?.autoValueType) { + // 이미 값이 있으면 덮어쓰지 않음 + const currentValue = formData[fieldName]; + console.log(`🔍 자동입력 필드 체크: ${fieldName}`, { + currentValue, + isEmpty: currentValue === undefined || currentValue === '', + isAutoInput, + autoValueType: config.autoValueType + }); + + if (currentValue === undefined || currentValue === '') { + const autoValue = config.autoValueType === "custom" + ? config.customValue || "" + : generateAutoValue(config.autoValueType); + + console.log("🔄 자동입력 필드 초기화:", { + fieldName, + autoValueType: config.autoValueType, + autoValue + }); + + updateFormData(fieldName, autoValue); + } else { + console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`); + } + } + } + } + }); + }; + + // 초기 로드 시 자동입력 필드들 설정 + initAutoInputFields(); + }, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지) + // 날짜 값 업데이트 const updateDateValue = (fieldName: string, date: Date | undefined) => { setDateValues((prev) => ({ @@ -125,6 +275,17 @@ export const InteractiveScreenViewer: React.FC = ( const widget = comp as WidgetComponent; const config = widget.webTypeConfig as TextTypeConfig | undefined; + // 자동입력 관련 처리 + const isAutoInput = config?.autoInput || false; + const autoValue = isAutoInput && config?.autoValueType + ? config.autoValueType === "custom" + ? config.customValue || "" + : generateAutoValue(config.autoValueType) + : ""; + + // 기본값 또는 자동값 설정 + const displayValue = isAutoInput ? autoValue : currentValue || config?.defaultValue || ""; + console.log("📝 InteractiveScreenViewer - Text 위젯:", { componentId: widget.id, widgetType: widget.widgetType, @@ -135,6 +296,11 @@ export const InteractiveScreenViewer: React.FC = ( maxLength: config?.maxLength, pattern: config?.pattern, placeholder: config?.placeholder, + defaultValue: config?.defaultValue, + autoInput: isAutoInput, + autoValueType: config?.autoValueType, + autoValue, + displayValue, }, }); @@ -163,6 +329,7 @@ export const InteractiveScreenViewer: React.FC = ( // 입력 검증 함수 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; + console.log(`📝 입력 변경: ${fieldName} = "${value}"`); // 형식별 실시간 검증 if (config?.format && config.format !== "none") { @@ -170,6 +337,7 @@ export const InteractiveScreenViewer: React.FC = ( if (pattern) { const regex = new RegExp(`^${pattern}$`); if (value && !regex.test(value)) { + console.log(`❌ 형식 검증 실패: ${fieldName} = "${value}"`); return; // 유효하지 않은 입력 차단 } } @@ -177,9 +345,11 @@ export const InteractiveScreenViewer: React.FC = ( // 길이 제한 검증 if (config?.maxLength && value.length > config.maxLength) { + console.log(`❌ 길이 제한 초과: ${fieldName} = "${value}" (최대: ${config.maxLength})`); return; // 최대 길이 초과 차단 } + console.log(`✅ updateFormData 호출: ${fieldName} = "${value}"`); updateFormData(fieldName, value); }; @@ -189,15 +359,16 @@ export const InteractiveScreenViewer: React.FC = ( return applyStyles( = ( case "save": await handleSaveAction(); break; - case "cancel": - handleCancelAction(); - break; case "delete": await handleDeleteAction(); break; @@ -742,8 +910,23 @@ export const InteractiveScreenViewer: React.FC = ( // 저장 액션 const handleSaveAction = async () => { - if (!formData || Object.keys(formData).length === 0) { - alert("저장할 데이터가 없습니다."); + // 저장 시점에서 최신 formData 구성 + const currentFormData = { ...localFormData, ...externalFormData }; + console.log("💾 저장 시작 - currentFormData:", currentFormData); + console.log("💾 저장 시점 formData 상세:", { + local: localFormData, + external: externalFormData, + merged: currentFormData + }); + console.log("💾 currentFormData 키-값 상세:"); + Object.entries(currentFormData).forEach(([key, value]) => { + console.log(` ${key}: "${value}" (타입: ${typeof value})`); + }); + + // formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행) + const hasWidgets = allComponents.some(comp => comp.type === 'widget'); + if (!hasWidgets) { + alert("저장할 입력 컴포넌트가 없습니다."); return; } @@ -751,7 +934,7 @@ export const InteractiveScreenViewer: React.FC = ( const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id)); const missingFields = requiredFields.filter(field => { const fieldName = field.columnName || field.id; - const value = formData[fieldName]; + const value = currentFormData[fieldName]; return !value || value.toString().trim() === ""; }); @@ -770,27 +953,93 @@ export const InteractiveScreenViewer: React.FC = ( // 컬럼명 기반으로 데이터 매핑 const mappedData: Record = {}; - // 컴포넌트에서 컬럼명이 있는 것들만 매핑 + // 입력 가능한 컴포넌트에서 데이터 수집 allComponents.forEach(comp => { - if (comp.columnName) { - const fieldName = comp.columnName; - const componentId = comp.id; + // 위젯 컴포넌트이고 입력 가능한 타입인 경우 + if (comp.type === 'widget') { + const widget = comp as WidgetComponent; + const fieldName = widget.columnName || widget.id; + let value = currentFormData[fieldName]; - // formData에서 해당 값 찾기 (컬럼명 우선, 없으면 컴포넌트 ID) - const value = formData[fieldName] || formData[componentId]; + console.log(`🔍 컴포넌트 처리: ${fieldName}`, { + widgetType: widget.widgetType, + formDataValue: value, + hasWebTypeConfig: !!widget.webTypeConfig, + config: widget.webTypeConfig + }); - if (value !== undefined && value !== "") { - mappedData[fieldName] = value; + // 자동입력 필드인 경우에만 값이 없을 때 생성 + if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && + widget.webTypeConfig) { + const config = widget.webTypeConfig as TextTypeConfig; + const isAutoInput = config?.autoInput || false; + + console.log(`📋 ${fieldName} 자동입력 체크:`, { + isAutoInput, + autoValueType: config?.autoValueType, + hasValue: !!value, + value + }); + + if (isAutoInput && config?.autoValueType && (!value || value === '')) { + // 자동입력이고 값이 없을 때만 생성 + value = config.autoValueType === "custom" + ? config.customValue || "" + : generateAutoValue(config.autoValueType); + + console.log("💾 자동입력 값 저장 (값이 없어서 생성):", { + fieldName, + autoValueType: config.autoValueType, + generatedValue: value + }); + } else if (isAutoInput && value) { + console.log("💾 자동입력 필드지만 기존 값 유지:", { + fieldName, + existingValue: value + }); + } else if (!isAutoInput) { + console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`); + } + } + + // 값이 있는 경우만 매핑 (빈 문자열도 포함하되, undefined는 제외) + if (value !== undefined && value !== null && value !== "undefined") { + // columnName이 있으면 columnName을 키로, 없으면 컴포넌트 ID를 키로 사용 + const saveKey = widget.columnName || `comp_${widget.id}`; + mappedData[saveKey] = value; + } else if (widget.columnName) { + // 값이 없지만 columnName이 있는 경우, 빈 문자열로 저장 + console.log(`⚠️ ${widget.columnName} 필드에 값이 없어 빈 문자열로 저장`); + mappedData[widget.columnName] = ""; } } }); console.log("💾 저장할 데이터 매핑:", { - 원본데이터: formData, + 원본데이터: currentFormData, 매핑된데이터: mappedData, 화면정보: screenInfo, + 전체컴포넌트수: allComponents.length, + 위젯컴포넌트수: allComponents.filter(c => c.type === 'widget').length, }); + // 각 컴포넌트의 상세 정보 로그 + console.log("🔍 컴포넌트별 데이터 수집 상세:"); + allComponents.forEach(comp => { + if (comp.type === 'widget') { + const widget = comp as WidgetComponent; + const fieldName = widget.columnName || widget.id; + const value = currentFormData[fieldName]; + const hasValue = value !== undefined && value !== null && value !== ''; + console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`); + } + }); + + // 매핑된 데이터가 비어있으면 경고 + if (Object.keys(mappedData).length === 0) { + console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다."); + } + // 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용) const tableName = screenInfo.tableName || allComponents.find(c => c.columnName)?.tableName || @@ -812,9 +1061,11 @@ export const InteractiveScreenViewer: React.FC = ( // 저장 후 데이터 초기화 (선택사항) if (onFormDataChange) { + const resetData: Record = {}; Object.keys(formData).forEach(key => { - onFormDataChange(key, ""); + resetData[key] = ""; }); + onFormDataChange(resetData); } } else { throw new Error(result.message || "저장에 실패했습니다."); @@ -825,19 +1076,6 @@ export const InteractiveScreenViewer: React.FC = ( } }; - // 취소 액션 - const handleCancelAction = () => { - if (confirm("변경사항을 취소하시겠습니까?")) { - // 폼 초기화 또는 이전 페이지로 이동 - if (onFormDataChange) { - // 모든 폼 데이터 초기화 - Object.keys(formData).forEach(key => { - onFormDataChange(key, ""); - }); - } - console.log("❌ 작업이 취소되었습니다."); - } - }; // 삭제 액션 const handleDeleteAction = async () => { @@ -876,9 +1114,11 @@ export const InteractiveScreenViewer: React.FC = ( // 삭제 후 폼 초기화 if (onFormDataChange) { + const resetData: Record = {}; Object.keys(formData).forEach(key => { - onFormDataChange(key, ""); + resetData[key] = ""; }); + onFormDataChange(resetData); } } else { throw new Error(result.message || "삭제에 실패했습니다."); @@ -919,9 +1159,11 @@ export const InteractiveScreenViewer: React.FC = ( const handleResetAction = () => { if (confirm("모든 입력을 초기화하시겠습니까?")) { if (onFormDataChange) { + const resetData: Record = {}; Object.keys(formData).forEach(key => { - onFormDataChange(key, ""); + resetData[key] = ""; }); + onFormDataChange(resetData); } console.log("🔄 폼 초기화 완료"); alert("입력이 초기화되었습니다."); @@ -937,35 +1179,92 @@ export const InteractiveScreenViewer: React.FC = ( // 닫기 액션 const handleCloseAction = () => { - console.log("❌ 창 닫기"); - // 창 닫기 또는 모달 닫기 - if (window.opener) { + console.log("❌ 닫기 액션 실행"); + + // 모달 내부에서 실행되는지 확인 + const isInModal = document.querySelector('[role="dialog"]') !== null; + const isInPopup = window.opener !== null; + + if (isInModal) { + // 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생 + console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도"); + + // 모달의 닫기 버튼을 찾아서 클릭 + const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close'); + if (modalCloseButton) { + (modalCloseButton as HTMLElement).click(); + } else { + // ESC 키 이벤트 발생시키기 + const escEvent = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27 }); + document.dispatchEvent(escEvent); + } + } else if (isInPopup) { + // 팝업 창인 경우 + console.log("🔄 팝업 창 닫기"); window.close(); } else { - history.back(); + // 일반 페이지인 경우 - 이전 페이지로 이동하지 않고 아무것도 하지 않음 + console.log("🔄 일반 페이지에서 닫기 - 아무 동작 하지 않음"); + alert("닫기 버튼이 클릭되었습니다."); } }; // 팝업 액션 const handlePopupAction = () => { - if (config?.popupTitle && config?.popupContent) { - // 커스텀 모달 대신 기본 alert 사용 (향후 모달 컴포넌트로 교체 가능) + console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId }); + + if (config?.popupScreenId) { + // 화면 모달 열기 + setPopupScreen({ + screenId: config.popupScreenId, + title: config.popupTitle || "상세 정보", + size: "lg", + }); + } else if (config?.popupTitle && config?.popupContent) { + // 텍스트 모달 표시 alert(`${config.popupTitle}\n\n${config.popupContent}`); } else { - alert("팝업을 표시합니다."); + alert("모달을 표시합니다."); } }; // 네비게이션 액션 const handleNavigateAction = () => { - if (config?.navigateUrl) { + const navigateType = config?.navigateType || "url"; + + if (navigateType === "screen" && config?.navigateScreenId) { + // 화면으로 이동 + const screenPath = `/screens/${config.navigateScreenId}`; + + console.log("🎯 화면으로 이동:", { + screenId: config.navigateScreenId, + target: config.navigateTarget || "_self", + path: screenPath + }); + + if (config.navigateTarget === "_blank") { + window.open(screenPath, "_blank"); + } else { + window.location.href = screenPath; + } + } else if (navigateType === "url" && config?.navigateUrl) { + // URL로 이동 + console.log("🔗 URL로 이동:", { + url: config.navigateUrl, + target: config.navigateTarget || "_self" + }); + if (config.navigateTarget === "_blank") { window.open(config.navigateUrl, "_blank"); } else { window.location.href = config.navigateUrl; } } else { - console.log("🔗 네비게이션 URL이 설정되지 않았습니다."); + console.log("🔗 네비게이션 정보가 설정되지 않았습니다:", { + navigateType, + hasUrl: !!config?.navigateUrl, + hasScreenId: !!config?.navigateScreenId + }); } }; @@ -991,7 +1290,7 @@ export const InteractiveScreenViewer: React.FC = ( + + + ) : ( + // 기본 할당 화면 + <> + + + + 메뉴에 화면 할당 + + + 저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다. + + {screenInfo && ( +
+
+ + {screenInfo.screenName} + + {screenInfo.screenCode} + +
+ {screenInfo.description &&

{screenInfo.description}

} +
+ )} +
+ +
+ {/* 메뉴 선택 (검색 기능 포함) */} +
+ + { + e.stopPropagation(); // 이벤트 전파 방지 + setSearchTerm(e.target.value); + }} + onKeyDown={(e) => { + e.stopPropagation(); // 키보드 이벤트 전파 방지 + }} + onClick={(e) => { + e.stopPropagation(); // 클릭 이벤트 전파 방지 + }} + className="h-8 pr-8 pl-10 text-sm" + /> + {searchTerm && ( + + )} +
+
+ {/* 메뉴 옵션들 */} +
{getMenuOptions()}
+ + +
+ + {/* 선택된 메뉴 정보 */} + {selectedMenu && ( +
+
+
+
+

{selectedMenu.menu_name_kor}

+ 관리자 + + {selectedMenu.status === "active" ? "활성" : "비활성"} + +
+
+ {selectedMenu.menu_url &&

URL: {selectedMenu.menu_url}

} + {selectedMenu.menu_desc &&

설명: {selectedMenu.menu_desc}

} + {selectedMenu.company_name &&

회사: {selectedMenu.company_name}

} +
+ + {/* 기존 할당된 화면 정보 */} + {existingScreens.length > 0 && ( +
+

+ ⚠️ 이미 할당된 화면 ({existingScreens.length}개) +

+
+ {existingScreens.map((screen) => ( +
+ + {screen.screenName} + + {screen.screenCode} + +
+ ))} +
+

새 화면을 할당하면 기존 화면들이 제거됩니다.

+
+ )} +
+
+
+ )} +
+ + + + + + + )} + + + + {/* 화면 교체 확인 대화상자 */} + + + + + + 화면 교체 확인 + + 선택한 메뉴에 이미 할당된 화면이 있습니다. + + +
+ {/* 기존 화면 목록 */} +
+

제거될 화면 ({existingScreens.length}개):

+
+ {existingScreens.map((screen) => ( +
+ + {screen.screenName} + + {screen.screenCode} + +
+ ))} +
+
+ + {/* 새로 할당될 화면 */} + {screenInfo && ( +
+

새로 할당될 화면:

+
+ + {screenInfo.screenName} + + {screenInfo.screenCode} + +
+
+ )} + +
+

+ 주의: 기존 화면들이 메뉴에서 제거되고 새 화면으로 교체됩니다. 이 작업은 되돌릴 수 + 없습니다. +

+
+
+ + + + + +
+
+ + ); +}; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 24433a26..3e77db90 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 { @@ -35,6 +37,7 @@ import { import { GroupingToolbar } from "./GroupingToolbar"; import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { toast } from "sonner"; +import { MenuAssignmentModal } from "./MenuAssignmentModal"; import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreview"; @@ -45,6 +48,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 +106,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 +134,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }); const [isSaving, setIsSaving] = useState(false); + // 메뉴 할당 모달 상태 + const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); + + // 해상도 설정 상태 + const [screenResolution, setScreenResolution] = useState( + SCREEN_RESOLUTIONS[0], // 기본값: Full HD + ); + const [selectedComponent, setSelectedComponent] = useState(null); // 클립보드 상태 @@ -171,37 +191,42 @@ 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 (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - width = rect.width || width; - height = rect.height || height; + // 해상도가 설정되지 않은 경우 기본값 사용 + if (!width || !height) { + width = canvasSize.width || window.innerWidth - 100; + height = canvasSize.height || window.innerHeight - 200; } - return calculateGridInfo(width, height, { + const newGridInfo = calculateGridInfo(width, height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }); - }, [layout.gridSettings, canvasSize]); + + console.log("🧮 격자 정보 재계산:", { + resolution: `${width}x${height}`, + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + columnWidth: newGridInfo.columnWidth.toFixed(2), + snapToGrid: layout.gridSettings.snapToGrid, + }); + + return newGridInfo; + }, [layout.gridSettings, 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 +242,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ]; return allLines; - }, [gridInfo, layout.gridSettings]); + }, [gridInfo, layout.gridSettings, screenResolution]); // 필터된 테이블 목록 const filteredTables = useMemo(() => { @@ -341,11 +366,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD gridInfo && newComp.type !== "group" ) { - const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); + // 현재 해상도에 맞는 격자 정보로 스냅 적용 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, layout.gridSettings as GridUtilSettings); newComp.size = snappedSize; // 크기 변경 시 gridColumns도 자동 조정 - const adjustedColumns = adjustGridColumnsFromSize(newComp, gridInfo, layout.gridSettings as GridUtilSettings); + const adjustedColumns = adjustGridColumnsFromSize( + newComp, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); if (newComp.gridColumns !== adjustedColumns) { newComp.gridColumns = adjustedColumns; console.log("📏 크기 변경으로 gridColumns 자동 조정:", { @@ -356,15 +392,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } } + // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 + if (path === "gridColumns" && layout.gridSettings?.snapToGrid && newComp.type !== "group") { + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + + // gridColumns에 맞는 정확한 너비 계산 + const newWidth = calculateWidthFromColumns( + newComp.gridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + newComp.size = { + ...newComp.size, + width: newWidth, + }; + + console.log("📐 gridColumns 변경으로 크기 자동 조정:", { + componentId, + gridColumns: newComp.gridColumns, + oldWidth: comp.size.width, + newWidth: newWidth, + columnWidth: currentGridInfo.columnWidth, + gap: layout.gridSettings.gap, + }); + } + // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) if ( (path === "position.x" || path === "position.y" || path === "position") && - layout.gridSettings?.snapToGrid && - gridInfo + layout.gridSettings?.snapToGrid ) { + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 - if (newComp.parentId && gridInfo) { - const { columnWidth } = gridInfo; + if (newComp.parentId && currentGridInfo) { + const { columnWidth } = currentGridInfo; const { gap } = layout.gridSettings; // 그룹 내부 패딩 고려한 격자 정렬 @@ -416,7 +489,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }); } else if (newComp.type !== "group") { // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 - const snappedPosition = snapToGrid(newComp.position, gridInfo, layout.gridSettings as GridUtilSettings); + const snappedPosition = snapToGrid( + newComp.position, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); newComp.position = snappedPosition; console.log("🧲 일반 컴포넌트 격자 스냅:", { @@ -541,6 +618,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 +650,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,24 +692,130 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setLayout(newLayout); saveToHistory(newLayout); }, - [layout, canvasSize, saveToHistory], + [layout, screenResolution, saveToHistory], ); + // 해상도 변경 핸들러 + const handleResolutionChange = useCallback( + (newResolution: ScreenResolution) => { + console.log("📱 해상도 변경 시작:", { + from: `${screenResolution.width}x${screenResolution.height}`, + to: `${newResolution.width}x${newResolution.height}`, + hasComponents: layout.components.length > 0, + snapToGrid: layout.gridSettings?.snapToGrid || false, + }); + + setScreenResolution(newResolution); + + // 해상도 변경 시에는 격자 스냅을 적용하지 않고 해상도 정보만 업데이트 + // 이는 기존 컴포넌트들의 위치를 보존하기 위함 + const updatedLayout = { + ...layout, + screenResolution: newResolution, + }; + + setLayout(updatedLayout); + saveToHistory(updatedLayout); + + console.log("✅ 해상도 변경 완료:", { + newResolution: `${newResolution.width}x${newResolution.height}`, + preservedComponents: layout.components.length, + note: "컴포넌트 위치는 보존됨 (격자 스냅 생략)", + }); + }, + [layout, saveToHistory, screenResolution], + ); + + // 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용) + const handleForceGridUpdate = useCallback(() => { + if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) { + console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음"); + return; + } + + console.log("🔄 격자 강제 재조정 시작:", { + componentsCount: layout.components.length, + resolution: `${screenResolution.width}x${screenResolution.height}`, + gridSettings: layout.gridSettings, + }); + + // 현재 해상도로 격자 정보 계산 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.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, currentGridInfo, gridUtilSettings); + const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings); + + // gridColumns가 없거나 범위를 벗어나면 자동 조정 + let adjustedGridColumns = comp.gridColumns; + if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) { + adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings); + } + + return { + ...comp, + position: snappedPosition, + size: snappedSize, + gridColumns: adjustedGridColumns, + }; + }); + + const newLayout = { ...layout, components: adjustedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + console.log("✅ 격자 강제 재조정 완료:", { + adjustedComponents: adjustedComponents.length, + gridInfo: { + columnWidth: currentGridInfo.columnWidth.toFixed(2), + totalWidth: currentGridInfo.totalWidth, + columns: layout.gridSettings.columns, + }, + }); + + toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`); + }, [layout, screenResolution, saveToHistory]); + // 저장 const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) return; try { setIsSaving(true); - await screenApi.saveLayout(selectedScreen.screenId, layout); + // 해상도 정보를 포함한 레이아웃 데이터 생성 + const layoutWithResolution = { + ...layout, + screenResolution: screenResolution, + }; + console.log("💾 저장할 레이아웃 데이터:", { + componentsCount: layoutWithResolution.components.length, + gridSettings: layoutWithResolution.gridSettings, + screenResolution: layoutWithResolution.screenResolution, + }); + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); toast.success("화면이 저장되었습니다."); + + // 저장 성공 후 메뉴 할당 모달 열기 + setShowMenuAssignmentModal(true); } catch (error) { console.error("저장 실패:", error); toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } - }, [selectedScreen?.screenId, layout]); + }, [selectedScreen?.screenId, layout, screenResolution]); // 템플릿 드래그 처리 const handleTemplateDrop = useCallback( @@ -630,10 +826,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const dropX = e.clientX - rect.left; const dropY = e.clientY - rect.top; + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + // 격자 스냅 적용 const snappedPosition = - layout.gridSettings?.snapToGrid && gridInfo - ? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings as GridUtilSettings) + layout.gridSettings?.snapToGrid && currentGridInfo + ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) : { x: dropX, y: dropY, z: 1 }; console.log("🎨 템플릿 드롭:", { @@ -664,8 +870,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 격자 스냅 적용 const finalPosition = - layout.gridSettings?.snapToGrid && gridInfo - ? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings as GridUtilSettings) + layout.gridSettings?.snapToGrid && currentGridInfo + ? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) : { x: absoluteX, y: absoluteY, z: 1 }; if (templateComp.type === "container") { @@ -674,11 +880,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼 const calculatedSize = - gridInfo && layout.gridSettings?.snapToGrid + currentGridInfo && layout.gridSettings?.snapToGrid ? (() => { const newWidth = calculateWidthFromColumns( gridColumns, - gridInfo, + currentGridInfo, layout.gridSettings as GridUtilSettings, ); return { @@ -712,11 +918,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // gridColumns에 맞는 크기 계산 const calculatedSize = - gridInfo && layout.gridSettings?.snapToGrid + currentGridInfo && layout.gridSettings?.snapToGrid ? (() => { const newWidth = calculateWidthFromColumns( gridColumns, - gridInfo, + currentGridInfo, layout.gridSettings as GridUtilSettings, ); return { @@ -730,7 +936,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD gridColumns, templateSize: templateComp.size, calculatedSize, - hasGridInfo: !!gridInfo, + hasGridInfo: !!currentGridInfo, hasGridSettings: !!layout.gridSettings?.snapToGrid, }); @@ -842,6 +1048,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }; + // 위젯 크기도 격자에 맞게 조정 + const widgetSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? { + width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings), + height: templateComp.size.height, + } + : templateComp.size; + return { id: componentId, type: "widget", @@ -851,7 +1066,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD columnName: `field_${index + 1}`, parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined, position: finalPosition, - size: templateComp.size, + size: widgetSize, required: templateComp.required || false, readonly: templateComp.readonly || false, gridColumns: 1, @@ -942,8 +1157,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, }; } else if (type === "column") { - // 격자 기반 컬럼 너비 계산 - const columnWidth = gridInfo ? gridInfo.columnWidth : 200; + // 현재 해상도에 맞는 격자 정보로 기본 크기 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + + // 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값 + const defaultWidth = + currentGridInfo && layout.gridSettings?.snapToGrid + ? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings) + : 200; + + console.log("🎯 컴포넌트 생성 시 크기 계산:", { + screenResolution: `${screenResolution.width}x${screenResolution.height}`, + gridSettings: layout.gridSettings, + currentGridInfo: currentGridInfo + ? { + columnWidth: currentGridInfo.columnWidth.toFixed(2), + totalWidth: currentGridInfo.totalWidth, + } + : null, + defaultWidth: defaultWidth.toFixed(2), + snapToGrid: layout.gridSettings?.snapToGrid, + }); // 웹타입별 기본 설정 생성 const getDefaultWebTypeConfig = (widgetType: string) => { @@ -1091,7 +1332,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD readonly: false, parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 position: { x: relativeX, y: relativeY, z: 1 } as Position, - size: { width: 200, height: 40 }, + size: { width: defaultWidth, height: 40 }, style: { labelDisplay: true, labelFontSize: "12px", @@ -1116,7 +1357,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD required: column.required, readonly: false, position: { x, y, z: 1 } as Position, - size: { width: columnWidth, height: 40 }, + size: { width: defaultWidth, height: 40 }, gridColumns: 1, style: { labelDisplay: true, @@ -1133,20 +1374,30 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } // 격자 스냅 적용 (그룹 컴포넌트 제외) - if (layout.gridSettings?.snapToGrid && gridInfo && newComponent.type !== "group") { + if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") { + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.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 || false, }; - newComponent.position = snapToGrid(newComponent.position, gridInfo, gridUtilSettings); - newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, gridUtilSettings); + newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings); + newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings); console.log("🧲 새 컴포넌트 격자 스냅 적용:", { type: newComponent.type, + resolution: `${screenResolution.width}x${screenResolution.height}`, snappedPosition: newComponent.position, snappedSize: newComponent.size, + columnWidth: currentGridInfo.columnWidth, }); } @@ -1320,15 +1571,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); let finalPosition = dragState.currentPosition; + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + // 일반 컴포넌트만 격자 스냅 적용 (그룹 제외) - if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && gridInfo) { + if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { finalPosition = snapToGrid( { x: dragState.currentPosition.x, y: dragState.currentPosition.y, z: dragState.currentPosition.z ?? 1, }, - gridInfo, + currentGridInfo, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, @@ -1336,6 +1597,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD snapToGrid: layout.gridSettings.snapToGrid || false, }, ); + + console.log("🎯 격자 스냅 적용됨:", { + resolution: `${screenResolution.width}x${screenResolution.height}`, + originalPosition: dragState.currentPosition, + snappedPosition: finalPosition, + columnWidth: currentGridInfo.columnWidth, + }); } // 스냅으로 인한 추가 이동 거리 계산 @@ -2194,7 +2462,17 @@ 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, + }; + console.log("⚡ 자동 저장할 레이아웃 데이터:", { + componentsCount: layoutWithResolution.components.length, + gridSettings: layoutWithResolution.gridSettings, + screenResolution: layoutWithResolution.screenResolution, + }); + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); toast.success("레이아웃이 저장되었습니다."); } catch (error) { console.error("레이아웃 저장 실패:", error); @@ -2259,230 +2537,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 +2939,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }; updateGridSettings(defaultSettings); }} + onForceGridUpdate={handleForceGridUpdate} + screenResolution={screenResolution} /> @@ -2657,6 +2962,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD /> + closePanel("resolution")} + position="right" + width={320} + height={400} + autoHeight={true} + > +
+ +
+
+ {/* 그룹 생성 툴바 (필요시) */} {false && groupState.selectedComponents.length > 1 && (
@@ -2676,6 +2996,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD />
)} + + {/* 메뉴 할당 모달 */} + setShowMenuAssignmentModal(false)} + screenInfo={selectedScreen} + onAssignmentComplete={() => { + console.log("메뉴 할당 완료"); + // 필요시 추가 작업 수행 + }} + onBackToList={onBackToList} + />
); } diff --git a/frontend/components/screen/panels/ButtonConfigPanel.tsx b/frontend/components/screen/panels/ButtonConfigPanel.tsx index a77bdc54..82436b98 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; @@ -31,7 +32,6 @@ interface ButtonConfigPanelProps { const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.ReactNode; color: string }[] = [ { value: "save", label: "저장", icon: , color: "#3b82f6" }, - { value: "cancel", label: "취소", icon: , color: "#6b7280" }, { value: "delete", label: "삭제", icon: , color: "#ef4444" }, { value: "edit", label: "수정", icon: , color: "#f59e0b" }, { value: "add", label: "추가", icon: , color: "#10b981" }, @@ -39,7 +39,7 @@ const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.R { value: "reset", label: "초기화", icon: , color: "#6b7280" }, { value: "submit", label: "제출", icon: , color: "#059669" }, { value: "close", label: "닫기", icon: , color: "#6b7280" }, - { value: "popup", label: "팝업 열기", icon: , color: "#8b5cf6" }, + { value: "popup", label: "모달 열기", icon: , color: "#8b5cf6" }, { value: "navigate", label: "페이지 이동", icon: , color: "#0ea5e9" }, { value: "custom", label: "사용자 정의", icon: , color: "#64748b" }, ]; @@ -52,7 +52,6 @@ export const ButtonConfigPanel: React.FC = ({ component, const defaultConfig = { actionType: "custom" as ButtonActionType, variant: "default" as ButtonVariant, - size: "sm" as ButtonSize, }; return { @@ -61,6 +60,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" || localConfig.actionType === "navigate") { + loadScreens(); + } + }, [localConfig.actionType]); + // 컴포넌트 변경 시 로컬 상태 동기화 useEffect(() => { const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {}; @@ -69,7 +92,6 @@ export const ButtonConfigPanel: React.FC = ({ component, const defaultConfig = { actionType: "custom" as ButtonActionType, variant: "default" as ButtonVariant, - size: "sm" as ButtonSize, }; // 실제 저장된 값이 우선순위를 가지도록 설정 @@ -121,13 +143,12 @@ export const ButtonConfigPanel: React.FC = ({ component, style: { ...component.style, backgroundColor: "#3b82f6", color: "#ffffff" }, }); break; - case "cancel": case "close": updates.variant = "outline"; updates.backgroundColor = "transparent"; updates.textColor = "#6b7280"; onUpdateComponent({ - label: actionType === "cancel" ? "취소" : "닫기", + label: "닫기", style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" }, }); break; @@ -186,7 +207,7 @@ export const ButtonConfigPanel: React.FC = ({ component, updates.backgroundColor = "#8b5cf6"; updates.textColor = "#ffffff"; updates.popupTitle = "상세 정보"; - updates.popupContent = "여기에 팝업 내용을 입력하세요."; + updates.popupContent = "여기에 모달 내용을 입력하세요."; updates.popupSize = "md"; onUpdateComponent({ label: "상세보기", @@ -196,6 +217,7 @@ export const ButtonConfigPanel: React.FC = ({ component, case "navigate": updates.backgroundColor = "#0ea5e9"; updates.textColor = "#ffffff"; + updates.navigateType = "url"; updates.navigateUrl = "/"; updates.navigateTarget = "_self"; onUpdateComponent({ @@ -313,21 +335,6 @@ export const ButtonConfigPanel: React.FC = ({ component,
- -
- - -
{/* 아이콘 설정 */} @@ -367,11 +374,36 @@ export const ButtonConfigPanel: React.FC = ({ component,
- + + + {localConfig.popupScreenId &&

선택된 화면이 모달로 열립니다

} +
+
+ updateConfig({ popupTitle: e.target.value })} @@ -379,32 +411,18 @@ export const ButtonConfigPanel: React.FC = ({ component, className="h-8 text-xs" />
-
- - -
-
- -