diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 6fe72d5e..d7c9b38d 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -4086,3 +4086,57 @@ model table_relationships_backup { @@ignore } + +model test_sales_info { + sales_no String @id @db.VarChar(20) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + product_type String? @db.VarChar(100) + machine_type String? @db.VarChar(100) + customer_project_name String? @db.VarChar(200) + expected_delivery_date DateTime? @db.Date + receiving_location String? @db.VarChar(200) + setup_location String? @db.VarChar(200) + equipment_direction String? @db.VarChar(100) + equipment_count Int? @default(0) + equipment_type String? @db.VarChar(100) + equipment_length Decimal? @db.Decimal(10,2) + manager_name String? @db.VarChar(100) + reg_date DateTime? @default(now()) @db.Timestamp(6) + status String? @default("진행중") @db.VarChar(50) + + // 관계 정의: 영업 정보에서 프로젝트로 + projects test_project_info[] +} + +model test_project_info { + project_no String @id @db.VarChar(200) + sales_no String? @db.VarChar(20) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + + // 프로젝트 전용 컬럼들 + project_status String? @default("PLANNING") @db.VarChar(50) + project_start_date DateTime? @db.Date + project_end_date DateTime? @db.Date + project_manager String? @db.VarChar(100) + project_description String? @db.Text + + // 시스템 관리 컬럼들 + created_by String? @db.VarChar(100) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(100) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + + // 관계 정의: 영업 정보 참조 + sales test_sales_info? @relation(fields: [sales_no], references: [sales_no]) + + @@index([sales_no], map: "idx_project_sales_no") + @@index([project_status], map: "idx_project_status") + @@index([customer_name], map: "idx_project_customer") + @@index([project_manager], map: "idx_project_manager") +} diff --git a/backend-node/src/controllers/buttonDataflowController.ts b/backend-node/src/controllers/buttonDataflowController.ts index 82ee5b64..38ca9d4c 100644 --- a/backend-node/src/controllers/buttonDataflowController.ts +++ b/backend-node/src/controllers/buttonDataflowController.ts @@ -167,6 +167,19 @@ export async function getDiagramRelationships( const relationships = (diagram.relationships as any)?.relationships || []; + console.log("🔍 백엔드 - 관계도 데이터:", { + diagramId: diagram.diagram_id, + diagramName: diagram.diagram_name, + relationshipsRaw: diagram.relationships, + relationshipsArray: relationships, + relationshipsCount: relationships.length, + }); + + // 각 관계의 구조도 로깅 + relationships.forEach((rel: any, index: number) => { + console.log(`🔍 백엔드 - 관계 ${index + 1}:`, rel); + }); + res.json({ success: true, data: relationships, @@ -213,14 +226,77 @@ export async function getRelationshipPreview( } // 관계 정보 찾기 - const relationship = (diagram.relationships as any)?.relationships?.find( + console.log("🔍 관계 미리보기 요청:", { + diagramId, + relationshipId, + diagramRelationships: diagram.relationships, + relationshipsArray: (diagram.relationships as any)?.relationships, + }); + + const relationships = (diagram.relationships as any)?.relationships || []; + console.log( + "🔍 사용 가능한 관계 목록:", + relationships.map((rel: any) => ({ + id: rel.id, + name: rel.relationshipName || rel.name, // relationshipName 사용 + sourceTable: rel.fromTable || rel.sourceTable, // fromTable 사용 + targetTable: rel.toTable || rel.targetTable, // toTable 사용 + originalData: rel, // 디버깅용 + })) + ); + + const relationship = relationships.find( (rel: any) => rel.id === relationshipId ); + console.log("🔍 찾은 관계:", relationship); + if (!relationship) { + console.log("❌ 관계를 찾을 수 없음:", { + requestedId: relationshipId, + availableIds: relationships.map((rel: any) => rel.id), + }); + + // 🔧 임시 해결책: 첫 번째 관계를 사용하거나 기본 응답 반환 + if (relationships.length > 0) { + console.log("🔧 첫 번째 관계를 대신 사용:", relationships[0].id); + + const fallbackRelationship = relationships[0]; + + console.log("🔍 fallback 관계 선택:", fallbackRelationship); + console.log("🔍 diagram.control 전체 구조:", diagram.control); + console.log("🔍 diagram.plan 전체 구조:", diagram.plan); + + const fallbackControl = Array.isArray(diagram.control) + ? diagram.control.find((c: any) => c.id === fallbackRelationship.id) + : null; + const fallbackPlan = Array.isArray(diagram.plan) + ? diagram.plan.find((p: any) => p.id === fallbackRelationship.id) + : null; + + console.log("🔍 찾은 fallback control:", fallbackControl); + console.log("🔍 찾은 fallback plan:", fallbackPlan); + + const fallbackPreviewData = { + relationship: fallbackRelationship, + control: fallbackControl, + plan: fallbackPlan, + conditionsCount: (fallbackControl as any)?.conditions?.length || 0, + actionsCount: (fallbackPlan as any)?.actions?.length || 0, + }; + + console.log("🔍 최종 fallback 응답 데이터:", fallbackPreviewData); + + res.json({ + success: true, + data: fallbackPreviewData, + }); + return; + } + res.status(404).json({ success: false, - message: "관계를 찾을 수 없습니다.", + message: `관계를 찾을 수 없습니다. 요청된 ID: ${relationshipId}, 사용 가능한 ID: ${relationships.map((rel: any) => rel.id).join(", ")}`, }); return; } diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 20c74714..47d99787 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -11,8 +11,8 @@ export const saveFormData = async ( const { companyCode, userId } = req.user as any; const { screenId, tableName, data } = req.body; - // 필수 필드 검증 - if (!screenId || !tableName || !data) { + // 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크) + if (screenId === undefined || screenId === null || !tableName || !data) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다. (screenId, tableName, data)", @@ -80,7 +80,7 @@ export const updateFormData = async ( }; const result = await dynamicFormService.updateFormData( - parseInt(id), + id, // parseInt 제거 - 문자열 ID 지원 tableName, formDataWithMeta ); @@ -168,7 +168,7 @@ export const deleteFormData = async ( }); } - await dynamicFormService.deleteFormData(parseInt(id), tableName); + await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원 res.json({ success: true, diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 56b0c42c..8a8d1dd7 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -87,6 +87,48 @@ export class DynamicFormService { return Boolean(value); } + // 날짜/시간 타입 처리 + if ( + lowerDataType.includes("date") || + lowerDataType.includes("timestamp") || + lowerDataType.includes("time") + ) { + if (typeof value === "string") { + // 빈 문자열이면 null 반환 + if (value.trim() === "") { + return null; + } + + try { + // YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성 + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`); + return new Date(value + "T00:00:00"); + } + // 다른 날짜 형식도 Date 객체로 변환 + else { + console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`); + return new Date(value); + } + } catch (error) { + console.error(`❌ 날짜 변환 실패: ${value}`, error); + return null; + } + } + + // 이미 Date 객체인 경우 그대로 반환 + if (value instanceof Date) { + return value; + } + + // 숫자인 경우 timestamp로 처리 + if (typeof value === "number") { + return new Date(value); + } + + return null; + } + // 기본적으로 문자열로 반환 return value; } @@ -479,7 +521,7 @@ export class DynamicFormService { const updateQuery = ` UPDATE ${tableName} SET ${setClause} - WHERE ${primaryKeyColumn} = $${values.length} + WHERE ${primaryKeyColumn} = $${values.length}::text RETURNING * `; @@ -507,7 +549,7 @@ export class DynamicFormService { * 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트) */ async updateFormData( - id: number, + id: string | number, tableName: string, data: Record ): Promise { @@ -552,6 +594,31 @@ export class DynamicFormService { } }); + // 컬럼 타입에 맞는 데이터 변환 (UPDATE용) + const columnInfo = await this.getTableColumnInfo(tableName); + console.log(`📊 테이블 ${tableName}의 컬럼 타입 정보:`, columnInfo); + + // 각 컬럼의 타입에 맞게 데이터 변환 + Object.keys(dataToUpdate).forEach((columnName) => { + const column = columnInfo.find((col) => col.column_name === columnName); + if (column) { + const originalValue = dataToUpdate[columnName]; + const convertedValue = this.convertValueForPostgreSQL( + originalValue, + column.data_type + ); + + if (originalValue !== convertedValue) { + console.log( + `🔄 UPDATE 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}` + ); + dataToUpdate[columnName] = convertedValue; + } + } + }); + + console.log("✅ UPDATE 타입 변환 완료된 데이터:", dataToUpdate); + console.log("🎯 실제 테이블에서 업데이트할 데이터:", { tableName, id, @@ -575,10 +642,36 @@ export class DynamicFormService { const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용 console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); + // 기본키 데이터 타입 조회하여 적절한 캐스팅 적용 + const primaryKeyInfo = (await prisma.$queryRawUnsafe(` + SELECT data_type + FROM information_schema.columns + WHERE table_name = '${tableName}' + AND column_name = '${primaryKeyColumn}' + AND table_schema = 'public' + `)) as any[]; + + let typeCastSuffix = ""; + if (primaryKeyInfo.length > 0) { + const dataType = primaryKeyInfo[0].data_type; + console.log(`🔍 기본키 ${primaryKeyColumn}의 데이터 타입: ${dataType}`); + + if (dataType.includes("character") || dataType.includes("text")) { + typeCastSuffix = "::text"; + } else if (dataType.includes("bigint")) { + typeCastSuffix = "::bigint"; + } else if ( + dataType.includes("integer") || + dataType.includes("numeric") + ) { + typeCastSuffix = "::numeric"; + } + } + const updateQuery = ` UPDATE ${tableName} SET ${setClause} - WHERE ${primaryKeyColumn} = $${values.length} + WHERE ${primaryKeyColumn} = $${values.length}${typeCastSuffix} RETURNING * `; @@ -640,7 +733,7 @@ export class DynamicFormService { * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) */ async deleteFormData( - id: number, + id: string | number, tableName: string, companyCode?: string ): Promise { @@ -650,12 +743,15 @@ export class DynamicFormService { tableName, }); - // 1. 먼저 테이블의 기본키 컬럼명을 동적으로 조회 + // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 const primaryKeyQuery = ` - SELECT kcu.column_name + SELECT kcu.column_name, c.data_type FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.columns c + ON kcu.column_name = c.column_name + AND kcu.table_name = c.table_name WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY' LIMIT 1 @@ -677,13 +773,37 @@ export class DynamicFormService { throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); } - const primaryKeyColumn = (primaryKeyResult[0] as any).column_name; - console.log("🔑 발견된 기본키 컬럼:", primaryKeyColumn); + const primaryKeyInfo = primaryKeyResult[0] as any; + const primaryKeyColumn = primaryKeyInfo.column_name; + const primaryKeyDataType = primaryKeyInfo.data_type; + console.log("🔑 발견된 기본키:", { + column: primaryKeyColumn, + dataType: primaryKeyDataType, + }); - // 2. 동적으로 발견된 기본키를 사용한 DELETE SQL 생성 + // 2. 데이터 타입에 맞는 타입 캐스팅 적용 + let typeCastSuffix = ""; + if ( + primaryKeyDataType.includes("character") || + primaryKeyDataType.includes("text") + ) { + typeCastSuffix = "::text"; + } else if ( + primaryKeyDataType.includes("integer") || + primaryKeyDataType.includes("bigint") + ) { + typeCastSuffix = "::bigint"; + } else if ( + primaryKeyDataType.includes("numeric") || + primaryKeyDataType.includes("decimal") + ) { + typeCastSuffix = "::numeric"; + } + + // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` DELETE FROM ${tableName} - WHERE ${primaryKeyColumn} = $1 + WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 294cfa40..2530d403 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -18,6 +18,41 @@ const prisma = new PrismaClient(); export class TableManagementService { constructor() {} + /** + * 컬럼이 코드 타입인지 확인하고 코드 카테고리 반환 + */ + private async getCodeTypeInfo( + tableName: string, + columnName: string + ): Promise<{ isCodeType: boolean; codeCategory?: string }> { + try { + // column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인 + const result = await prisma.$queryRaw` + SELECT web_type, code_category + FROM column_labels + WHERE table_name = ${tableName} + AND column_name = ${columnName} + AND web_type = 'code' + `; + + if (Array.isArray(result) && result.length > 0) { + const row = result[0] as any; + return { + isCodeType: true, + codeCategory: row.code_category, + }; + } + + return { isCodeType: false }; + } catch (error) { + logger.warn( + `코드 타입 컬럼 확인 중 오류: ${tableName}.${columnName}`, + error + ); + return { isCodeType: false }; + } + } + /** * 테이블 목록 조회 (PostgreSQL information_schema 활용) * 메타데이터 조회는 Prisma로 변경 불가 @@ -915,8 +950,36 @@ export class TableManagementService { const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); if (typeof value === "string") { - whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`); - searchValues.push(`%${value}%`); + // 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색 + const codeTypeInfo = await this.getCodeTypeInfo( + tableName, + safeColumn + ); + + if (codeTypeInfo.isCodeType && codeTypeInfo.codeCategory) { + // 코드 타입 컬럼: 코드값 또는 코드명으로 검색 + // 1) 컬럼 값이 직접 검색어와 일치하는 경우 + // 2) 컬럼 값이 코드값이고, 해당 코드의 코드명이 검색어와 일치하는 경우 + whereConditions.push(`( + ${safeColumn}::text ILIKE $${paramIndex} OR + EXISTS ( + SELECT 1 FROM code_info ci + WHERE ci.code_category = $${paramIndex + 1} + AND ci.code_value = ${safeColumn} + AND ci.code_name ILIKE $${paramIndex + 2} + ) + )`); + searchValues.push(`%${value}%`); // 직접 값 검색용 + searchValues.push(codeTypeInfo.codeCategory); // 코드 카테고리 + searchValues.push(`%${value}%`); // 코드명 검색용 + paramIndex += 2; // 추가 파라미터로 인해 인덱스 증가 + } else { + // 일반 컬럼: 기존 방식 + whereConditions.push( + `${safeColumn}::text ILIKE $${paramIndex}` + ); + searchValues.push(`%${value}%`); + } } else { whereConditions.push(`${safeColumn} = $${paramIndex}`); searchValues.push(value); diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index be3c2132..f87b4905 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -3,14 +3,18 @@ import React, { useState, useEffect } from "react"; import { MenuItem, MenuFormData, menuApi, LangKey } from "@/lib/api/menu"; import { companyAPI } from "@/lib/api/company"; +import { screenApi } from "@/lib/api/screen"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { toast } from "sonner"; +import { ChevronDown, Search } from "lucide-react"; import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang"; +import { ScreenDefinition } from "@/types/screen"; interface Company { company_code: string; @@ -70,6 +74,13 @@ export const MenuFormModal: React.FC = ({ langKey: "", }); + // 화면 할당 관련 상태 + const [urlType, setUrlType] = useState<"direct" | "screen">("screen"); // URL 직접 입력 or 화면 할당 (기본값: 화면 할당) + const [selectedScreen, setSelectedScreen] = useState(null); + const [screens, setScreens] = useState([]); + const [screenSearchText, setScreenSearchText] = useState(""); + const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); const [isEdit, setIsEdit] = useState(false); const [companies, setCompanies] = useState([]); @@ -77,6 +88,132 @@ export const MenuFormModal: React.FC = ({ const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false); const [langKeySearchText, setLangKeySearchText] = useState(""); + // 화면 목록 로드 + const loadScreens = async () => { + try { + const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기 + + console.log("🔍 화면 목록 로드 디버깅:", { + totalScreens: response.data.length, + firstScreen: response.data[0], + firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [], + firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [], + allScreenIds: response.data + .map((s) => ({ + screenId: s.screenId, + legacyId: s.id, + name: s.screenName, + code: s.screenCode, + })) + .slice(0, 5), // 처음 5개만 출력 + }); + + setScreens(response.data); + console.log("✅ 화면 목록 로드 완료:", response.data.length); + } catch (error) { + console.error("❌ 화면 목록 로드 실패:", error); + toast.error("화면 목록을 불러오는데 실패했습니다."); + } + }; + + // 화면 선택 시 URL 자동 설정 + const handleScreenSelect = (screen: ScreenDefinition) => { + console.log("🖥️ 화면 선택 디버깅:", { + screen, + screenId: screen.screenId, + screenIdType: typeof screen.screenId, + legacyId: screen.id, + allFields: Object.keys(screen), + screenValues: Object.values(screen), + }); + + // ScreenDefinition에서는 screenId 필드를 사용 + const actualScreenId = screen.screenId || screen.id; + + if (!actualScreenId) { + console.error("❌ 화면 ID를 찾을 수 없습니다:", screen); + toast.error("화면 ID를 찾을 수 없습니다. 다른 화면을 선택해주세요."); + return; + } + + setSelectedScreen(screen); + setIsScreenDropdownOpen(false); + + // 실제 라우팅 패턴에 맞게 URL 생성: /screens/[screenId] (복수형) + // 관리자 메뉴인 경우 mode=admin 파라미터 추가 + let screenUrl = `/screens/${actualScreenId}`; + + // 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin") + const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; + if (isAdminMenu) { + screenUrl += "?mode=admin"; + } + + setFormData((prev) => ({ + ...prev, + menuUrl: screenUrl, + })); + + console.log("🖥️ 화면 선택 완료:", { + screenId: screen.screenId, + legacyId: screen.id, + actualScreenId, + screenName: screen.screenName, + menuType: menuType, + formDataMenuType: formData.menuType, + isAdminMenu, + generatedUrl: screenUrl, + }); + }; + + // URL 타입 변경 시 처리 + const handleUrlTypeChange = (type: "direct" | "screen") => { + console.log("🔄 URL 타입 변경:", { + from: urlType, + to: type, + currentSelectedScreen: selectedScreen?.screenName, + currentUrl: formData.menuUrl, + }); + + setUrlType(type); + + if (type === "direct") { + // 직접 입력 모드로 변경 시 선택된 화면 초기화 + setSelectedScreen(null); + // URL 필드도 초기화 (사용자가 직접 입력할 수 있도록) + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } else { + // 화면 할당 모드로 변경 시 + // 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 + if (selectedScreen) { + console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName); + // 현재 선택된 화면으로 URL 재생성 + const actualScreenId = selectedScreen.screenId || selectedScreen.id; + let screenUrl = `/screens/${actualScreenId}`; + + // 관리자 메뉴인 경우 mode=admin 파라미터 추가 + const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; + if (isAdminMenu) { + screenUrl += "?mode=admin"; + } + + setFormData((prev) => ({ + ...prev, + menuUrl: screenUrl, + })); + } else { + // 선택된 화면이 없으면 URL만 초기화 + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } + } + }; + // loadMenuData 함수를 먼저 정의 const loadMenuData = async () => { console.log("loadMenuData 호출됨 - menuId:", menuId); @@ -124,11 +261,16 @@ export const MenuFormModal: React.FC = ({ convertedStatus = "INACTIVE"; } + const menuUrl = menu.menu_url || menu.MENU_URL || ""; + + // URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정) + const isScreenUrl = menuUrl.startsWith("/screens/"); + setFormData({ objid: menu.objid || menu.OBJID, parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", - menuUrl: menu.menu_url || menu.MENU_URL || "", + menuUrl: menuUrl, menuDesc: menu.menu_desc || menu.MENU_DESC || "", seq: menu.seq || menu.SEQ || 1, menuType: convertedMenuType, @@ -137,6 +279,57 @@ export const MenuFormModal: React.FC = ({ langKey: langKey, // 다국어 키 설정 }); + // URL 타입 설정 + if (isScreenUrl) { + setUrlType("screen"); + // "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출 + const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1]; + if (screenId) { + console.log("🔍 기존 메뉴에서 화면 ID 추출:", { + menuUrl, + screenId, + hasAdminParam: menuUrl.includes("mode=admin"), + currentScreensCount: screens.length, + }); + + // 화면 설정 함수 + const setScreenFromId = () => { + const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId); + if (screen) { + setSelectedScreen(screen); + console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", { + screen, + originalUrl: menuUrl, + hasAdminParam: menuUrl.includes("mode=admin"), + }); + return true; + } else { + console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", { + screenId, + availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), + }); + return false; + } + }; + + // 화면 목록이 이미 있으면 즉시 설정, 없으면 로드 완료 대기 + if (screens.length > 0) { + console.log("📋 화면 목록이 이미 로드됨 - 즉시 설정"); + setScreenFromId(); + } else { + console.log("⏳ 화면 목록 로드 대기 중..."); + // 화면 ID를 저장해두고, 화면 목록 로드 완료 후 설정 + setTimeout(() => { + console.log("🔄 재시도: 화면 목록 로드 후 설정"); + setScreenFromId(); + }, 500); + } + } + } else { + setUrlType("direct"); + setSelectedScreen(null); + } + console.log("설정된 폼 데이터:", { objid: menu.objid || menu.OBJID, parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", @@ -237,6 +430,35 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); + // 화면 목록 로드 + useEffect(() => { + if (isOpen) { + loadScreens(); + } + }, [isOpen]); + + // 화면 목록 로드 완료 후 기존 메뉴의 할당된 화면 설정 + useEffect(() => { + if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "screen") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/screens/")) { + const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1]; + if (screenId && !selectedScreen) { + console.log("🔄 화면 목록 로드 완료 - 기존 할당 화면 자동 설정"); + const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId); + if (screen) { + setSelectedScreen(screen); + console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", { + screenId, + screenName: screen.screenName, + menuUrl, + }); + } + } + } + } + }, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -245,16 +467,20 @@ export const MenuFormModal: React.FC = ({ setIsLangKeyDropdownOpen(false); setLangKeySearchText(""); } + if (!target.closest(".screen-dropdown")) { + setIsScreenDropdownOpen(false); + setScreenSearchText(""); + } }; - if (isLangKeyDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isLangKeyDropdownOpen]); + }, [isLangKeyDropdownOpen, isScreenDropdownOpen]); const loadCompanies = async () => { try { @@ -516,12 +742,108 @@ export const MenuFormModal: React.FC = ({
- handleInputChange("menuUrl", e.target.value)} - placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)} - /> + + {/* URL 타입 선택 */} + +
+ + +
+
+ + +
+
+ + {/* 화면 할당 */} + {urlType === "screen" && ( +
+ {/* 화면 선택 드롭다운 */} +
+ + + {isScreenDropdownOpen && ( +
+ {/* 검색 입력 */} +
+
+ + setScreenSearchText(e.target.value)} + className="pl-8" + /> +
+
+ + {/* 화면 목록 */} +
+ {screens + .filter( + (screen) => + screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()), + ) + .map((screen, index) => ( +
handleScreenSelect(screen)} + className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100" + > +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+
ID: {screen.screenId || screen.id || "N/A"}
+
+
+ ))} + {screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()), + ).length === 0 &&
검색 결과가 없습니다.
} +
+
+ )} +
+ + {/* 선택된 화면 정보 표시 */} + {selectedScreen && ( +
+
{selectedScreen.screenName}
+
코드: {selectedScreen.screenCode}
+
생성된 URL: {formData.menuUrl}
+
+ )} +
+ )} + + {/* URL 직접 입력 */} + {urlType === "direct" && ( + handleInputChange("menuUrl", e.target.value)} + placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)} + /> + )}
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 92194b7a..453ddfe8 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; @@ -45,24 +45,60 @@ export const ScreenModal: React.FC = ({ className }) => { let maxWidth = 800; // 최소 너비 let maxHeight = 600; // 최소 높이 - components.forEach((component) => { - const x = parseFloat(component.style?.positionX || "0"); - const y = parseFloat(component.style?.positionY || "0"); - const width = parseFloat(component.style?.width || "100"); - const height = parseFloat(component.style?.height || "40"); + console.log("🔍 화면 크기 계산 시작:", { componentsCount: components.length }); + + components.forEach((component, index) => { + // position과 size는 BaseComponent에서 별도 속성으로 관리 + const x = parseFloat(component.position?.x?.toString() || "0"); + const y = parseFloat(component.position?.y?.toString() || "0"); + const width = parseFloat(component.size?.width?.toString() || "100"); + const height = parseFloat(component.size?.height?.toString() || "40"); // 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산 const rightEdge = x + width; const bottomEdge = y + height; - maxWidth = Math.max(maxWidth, rightEdge + 50); // 여백 추가 - maxHeight = Math.max(maxHeight, bottomEdge + 50); // 여백 추가 + console.log( + `📏 컴포넌트 ${index + 1} (${component.id}): x=${x}, y=${y}, w=${width}, h=${height}, rightEdge=${rightEdge}, bottomEdge=${bottomEdge}`, + ); + + const newMaxWidth = Math.max(maxWidth, rightEdge + 100); // 여백 증가 + const newMaxHeight = Math.max(maxHeight, bottomEdge + 100); // 여백 증가 + + if (newMaxWidth > maxWidth || newMaxHeight > maxHeight) { + console.log(`🔄 크기 업데이트: ${maxWidth}×${maxHeight} → ${newMaxWidth}×${newMaxHeight}`); + maxWidth = newMaxWidth; + maxHeight = newMaxHeight; + } }); - return { - width: Math.min(maxWidth, window.innerWidth * 0.9), // 화면의 90%를 넘지 않도록 - height: Math.min(maxHeight, window.innerHeight * 0.8), // 화면의 80%를 넘지 않도록 + console.log("📊 컴포넌트 기반 계산 결과:", { maxWidth, maxHeight }); + + // 브라우저 크기 제한 확인 (더욱 관대하게 설정) + const maxAllowedWidth = window.innerWidth * 0.98; // 95% -> 98% + const maxAllowedHeight = window.innerHeight * 0.95; // 90% -> 95% + + console.log("📐 크기 제한 정보:", { + 계산된크기: { maxWidth, maxHeight }, + 브라우저제한: { maxAllowedWidth, maxAllowedHeight }, + 브라우저크기: { width: window.innerWidth, height: window.innerHeight }, + }); + + // 컴포넌트 기반 크기를 우선 적용하되, 브라우저 제한을 고려 + const finalDimensions = { + width: Math.min(maxWidth, maxAllowedWidth), + height: Math.min(maxHeight, maxAllowedHeight), }; + + console.log("✅ 최종 화면 크기:", finalDimensions); + console.log("🔧 크기 적용 분석:", { + width적용: maxWidth <= maxAllowedWidth ? "컴포넌트기준" : "브라우저제한", + height적용: maxHeight <= maxAllowedHeight ? "컴포넌트기준" : "브라우저제한", + 컴포넌트크기: { maxWidth, maxHeight }, + 최종크기: finalDimensions, + }); + + return finalDimensions; }; // 전역 모달 이벤트 리스너 @@ -154,17 +190,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; } - // 헤더 높이와 패딩을 고려한 전체 높이 계산 - const headerHeight = 60; // DialogHeader + 패딩 + // 헤더 높이와 패딩을 고려한 전체 높이 계산 (실제 측정값 기반) + const headerHeight = 80; // DialogHeader + 패딩 (더 정확한 값) const totalHeight = screenDimensions.height + headerHeight; return { className: "overflow-hidden p-0", style: { - width: `${screenDimensions.width + 48}px`, // 헤더 패딩과 여백 고려 - height: `${Math.min(totalHeight, window.innerHeight * 0.8)}px`, - maxWidth: "90vw", - maxHeight: "80vh", + width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 브라우저 제한 적용 + height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 브라우저 제한 적용 + maxWidth: "98vw", // 안전장치 + maxHeight: "95vh", // 안전장치 }, }; }; @@ -176,9 +212,10 @@ export const ScreenModal: React.FC = ({ className }) => { {modalState.title} + {loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."} -
+
{loading ? (
@@ -188,7 +225,7 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? (
= ({ className }) => { formData={formData} onFormDataChange={(fieldName, value) => { console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log(`📋 현재 formData:`, formData); + console.log("📋 현재 formData:", formData); setFormData((prev) => { const newFormData = { ...prev, [fieldName]: value, }; - console.log(`📝 ScreenModal 업데이트된 formData:`, newFormData); + console.log("📝 ScreenModal 업데이트된 formData:", newFormData); return newFormData; }); }} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2e736840..b0cc0c2f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { X, Save, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/lib/types/screen"; @@ -145,7 +146,19 @@ export const EditModal: React.FC = ({ layoutData.components.forEach((comp) => { if (comp.columnName) { const formValue = formData[comp.columnName]; - console.log(` - ${comp.columnName}: "${formValue}" (컴포넌트 ID: ${comp.id})`); + console.log( + ` - ${comp.columnName}: "${formValue}" (타입: ${comp.type}, 웹타입: ${(comp as any).widgetType})`, + ); + + // 코드 타입인 경우 특별히 로깅 + if ((comp as any).widgetType === "code") { + console.log(` 🔍 코드 타입 세부정보:`, { + columnName: comp.columnName, + componentId: comp.id, + formValue, + webTypeConfig: (comp as any).webTypeConfig, + }); + } } }); } else { @@ -230,6 +243,7 @@ export const EditModal: React.FC = ({ minHeight: dynamicSize.height, maxWidth: "95vw", maxHeight: "95vh", + zIndex: 9999, // 모든 컴포넌트보다 위에 표시 }} > @@ -269,30 +283,61 @@ export const EditModal: React.FC = ({ zIndex: 1, }} > - { - console.log("📝 폼 데이터 변경:", fieldName, value); - const newFormData = { ...formData, [fieldName]: value }; - setFormData(newFormData); + {/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */} + {component.type === "widget" ? ( + { + console.log("📝 폼 데이터 변경:", fieldName, value); + const newFormData = { ...formData, [fieldName]: value }; + setFormData(newFormData); - // 변경된 데이터를 즉시 부모로 전달 - if (onDataChange) { - console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); - onDataChange(newFormData); - } - }} - // 편집 모드로 설정 - mode="edit" - // 모달 내에서 렌더링되고 있음을 표시 - isInModal={true} - // 인터랙티브 모드 활성화 (formData 사용을 위해 필수) - isInteractive={true} - /> + // 변경된 데이터를 즉시 부모로 전달 + if (onDataChange) { + console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); + onDataChange(newFormData); + } + }} + screenInfo={{ + id: screenId || 0, + tableName: screenData.tableName, + }} + /> + ) : ( + { + console.log("📝 폼 데이터 변경:", fieldName, value); + const newFormData = { ...formData, [fieldName]: value }; + setFormData(newFormData); + + // 변경된 데이터를 즉시 부모로 전달 + if (onDataChange) { + console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); + onDataChange(newFormData); + } + }} + // 편집 모드로 설정 + mode="edit" + // 모달 내에서 렌더링되고 있음을 표시 + isInModal={true} + // 인터랙티브 모드 활성화 (formData 사용을 위해 필수) + isInteractive={true} + /> + )}
))}
diff --git a/frontend/components/screen/FloatingPanel.tsx b/frontend/components/screen/FloatingPanel.tsx index 2de7ed12..e6d3241e 100644 --- a/frontend/components/screen/FloatingPanel.tsx +++ b/frontend/components/screen/FloatingPanel.tsx @@ -227,7 +227,7 @@ export const FloatingPanel: React.FC = ({
= ({ height: `${panelSize.height}px`, transform: isDragging ? "scale(1.01)" : "scale(1)", transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out", - zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시 + zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시 }} > {/* 헤더 */} diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 6e5f26e4..41b88030 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1557,6 +1557,22 @@ export const InteractiveDataTable: React.FC = ({ ); + case "code": + // 코드 타입은 텍스트 검색으로 처리 (코드명으로 검색 가능) + return ( + handleSearchValueChange(filter.columnName, e.target.value)} + onKeyPress={(e) => { + if (e.key === "Enter") { + handleSearch(); + } + }} + /> + ); + default: return ( = ( const widget = comp as WidgetComponent; const config = widget.webTypeConfig as CodeTypeConfig | undefined; - console.log("💻 InteractiveScreenViewer - Code 위젯:", { + console.log("🔍 InteractiveScreenViewer - Code 위젯 (공통코드 선택):", { componentId: widget.id, widgetType: widget.widgetType, + columnName: widget.columnName, + fieldName, + currentValue, + formData, config, - appliedSettings: { - language: config?.language, - theme: config?.theme, - fontSize: config?.fontSize, - defaultValue: config?.defaultValue, - wordWrap: config?.wordWrap, - tabSize: config?.tabSize, - }, + codeCategory: config?.codeCategory, }); - const finalPlaceholder = config?.placeholder || "코드를 입력하세요..."; - const rows = config?.rows || 4; - - return applyStyles( -