diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b3ecbffb..746bf931 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; import { validateBusinessNumber } from "../utils/businessNumberValidator"; +import { MenuCopyService } from "../services/menuCopyService"; /** * 관리자 메뉴 목록 조회 @@ -3253,3 +3254,93 @@ export async function getTableSchema( }); } } + +/** + * 메뉴 복사 + * POST /api/admin/menus/:menuObjid/copy + */ +export async function copyMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const { targetCompanyCode } = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + const userType = req.user!.userType; + const isSuperAdmin = req.user!.isSuperAdmin; + + logger.info(` +=== 메뉴 복사 API 호출 === + menuObjid: ${menuObjid} + targetCompanyCode: ${targetCompanyCode} + userId: ${userId} + userCompanyCode: ${userCompanyCode} + userType: ${userType} + isSuperAdmin: ${isSuperAdmin} + `); + + // 권한 체크: 최고 관리자만 가능 + if (!isSuperAdmin && userType !== "SUPER_ADMIN") { + logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`); + res.status(403).json({ + success: false, + message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다", + error: { + code: "FORBIDDEN", + details: "Only super admin can copy menus", + }, + }); + return; + } + + // 필수 파라미터 검증 + if (!menuObjid || !targetCompanyCode) { + res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다", + error: { + code: "MISSING_PARAMETERS", + details: "menuObjid and targetCompanyCode are required", + }, + }); + return; + } + + // 화면명 변환 설정 (선택사항) + const screenNameConfig = req.body.screenNameConfig + ? { + removeText: req.body.screenNameConfig.removeText, + addPrefix: req.body.screenNameConfig.addPrefix, + } + : undefined; + + // 메뉴 복사 실행 + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + parseInt(menuObjid, 10), + targetCompanyCode, + userId, + screenNameConfig + ); + + logger.info("✅ 메뉴 복사 API 성공"); + + res.json({ + success: true, + message: "메뉴 복사 완료", + data: result, + }); + } catch (error: any) { + logger.error("❌ 메뉴 복사 API 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 복사 중 오류가 발생했습니다", + error: { + code: "MENU_COPY_ERROR", + details: error.message || "Unknown error", + }, + }); + } +} diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts index e38f2466..82043964 100644 --- a/backend-node/src/controllers/orderController.ts +++ b/backend-node/src/controllers/orderController.ts @@ -165,7 +165,7 @@ export async function createOrder(req: AuthenticatedRequest, res: Response) { } /** - * 수주 목록 조회 API + * 수주 목록 조회 API (마스터 + 품목 JOIN) * GET /api/orders */ export async function getOrders(req: AuthenticatedRequest, res: Response) { @@ -184,14 +184,14 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) { // 멀티테넌시 (writer 필드에 company_code 포함) if (companyCode !== "*") { - whereConditions.push(`writer LIKE $${paramIndex}`); + whereConditions.push(`m.writer LIKE $${paramIndex}`); params.push(`%${companyCode}%`); paramIndex++; } // 검색 if (searchText) { - whereConditions.push(`objid LIKE $${paramIndex}`); + whereConditions.push(`m.objid LIKE $${paramIndex}`); params.push(`%${searchText}%`); paramIndex++; } @@ -201,16 +201,47 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // 카운트 쿼리 - const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`; + // 카운트 쿼리 (고유한 수주 개수) + const countQuery = ` + SELECT COUNT(DISTINCT m.objid) as count + FROM order_mng_master m + ${whereClause} + `; const countResult = await pool.query(countQuery, params); const total = parseInt(countResult.rows[0]?.count || "0"); - // 데이터 쿼리 + // 데이터 쿼리 (마스터 + 품목 JOIN) const dataQuery = ` - SELECT * FROM order_mng_master + SELECT + m.objid as order_no, + m.partner_objid, + m.final_delivery_date, + m.reason, + m.status, + m.reg_date, + m.writer, + COALESCE( + json_agg( + CASE WHEN s.objid IS NOT NULL THEN + json_build_object( + 'sub_objid', s.objid, + 'part_objid', s.part_objid, + 'partner_price', s.partner_price, + 'partner_qty', s.partner_qty, + 'delivery_date', s.delivery_date, + 'status', s.status, + 'regdate', s.regdate + ) + END + ORDER BY s.regdate + ) FILTER (WHERE s.objid IS NOT NULL), + '[]'::json + ) as items + FROM order_mng_master m + LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid ${whereClause} - ORDER BY reg_date DESC + GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer + ORDER BY m.reg_date DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; @@ -219,6 +250,13 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) { const dataResult = await pool.query(dataQuery, params); + logger.info("수주 목록 조회 성공", { + companyCode, + total, + page: parseInt(page as string), + itemCount: dataResult.rows.length, + }); + res.json({ success: true, data: dataResult.rows, diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 378a38d9..188e5580 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -8,6 +8,7 @@ import { deleteMenu, // 메뉴 삭제 deleteMenusBatch, // 메뉴 일괄 삭제 toggleMenuStatus, // 메뉴 상태 토글 + copyMenu, // 메뉴 복사 getUserList, getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 @@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 +router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) router.put("/menus/:menuId", updateMenu); // 메뉴 수정 router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글 router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 9e06804b..4d33dc1c 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -320,19 +320,34 @@ export class DynamicFormService { Object.keys(dataToInsert).forEach((key) => { const value = dataToInsert[key]; - // RepeaterInput 데이터인지 확인 (JSON 배열 문자열) - if ( + // 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열) + let parsedArray: any[] | null = null; + + // 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등) + if (Array.isArray(value) && value.length > 0) { + parsedArray = value; + console.log( + `🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목` + ); + } + // 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput) + else if ( typeof value === "string" && value.trim().startsWith("[") && value.trim().endsWith("]") ) { try { - const parsedArray = JSON.parse(value); - if (Array.isArray(parsedArray) && parsedArray.length > 0) { + parsedArray = JSON.parse(value); console.log( - `🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목` + `🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목` ); + } catch (parseError) { + console.log(`⚠️ JSON 파싱 실패: ${key}`); + } + } + // 파싱된 배열이 있으면 처리 + if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) { // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 let targetTable: string | undefined; @@ -352,13 +367,34 @@ export class DynamicFormService { componentId: key, }); delete dataToInsert[key]; // 원본 배열 데이터는 제거 - } - } catch (parseError) { - console.log(`⚠️ JSON 파싱 실패: ${key}`); - } + + console.log(`✅ Repeater 데이터 추가: ${key}`, { + targetTable: targetTable || "없음 (화면 설계에서 설정 필요)", + itemCount: actualData.length, + firstItem: actualData[0], + }); } }); + // 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장 + const separateRepeaterData: typeof repeaterData = []; + const mergedRepeaterData: typeof repeaterData = []; + + repeaterData.forEach(repeater => { + if (repeater.targetTable && repeater.targetTable !== tableName) { + // 다른 테이블: 나중에 별도 저장 + separateRepeaterData.push(repeater); + } else { + // 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에) + mergedRepeaterData.push(repeater); + } + }); + + console.log(`🔄 Repeater 데이터 분류:`, { + separate: separateRepeaterData.length, // 별도 테이블 + merged: mergedRepeaterData.length, // 메인 테이블과 병합 + }); + // 존재하지 않는 컬럼 제거 Object.keys(dataToInsert).forEach((key) => { if (!tableColumns.includes(key)) { @@ -369,9 +405,6 @@ export class DynamicFormService { } }); - // RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리 - // (각 Repeater가 다른 테이블에 저장될 수 있으므로) - console.log("🎯 실제 테이블에 삽입할 데이터:", { tableName, dataToInsert, @@ -452,28 +485,106 @@ export class DynamicFormService { const userId = data.updated_by || data.created_by || "system"; const clientIp = ipAddress || "unknown"; - const result = await transaction(async (client) => { - // 세션 변수 설정 - await client.query(`SET LOCAL app.user_id = '${userId}'`); - await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); - - // UPSERT 실행 - const res = await client.query(upsertQuery, values); - return res.rows; - }); - - console.log("✅ 서비스: 실제 테이블 저장 성공:", result); + let result: any[]; + + // 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT + if (mergedRepeaterData.length > 0) { + console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`); + + result = []; + + for (const repeater of mergedRepeaterData) { + for (const item of repeater.data) { + // 헤더 + 품목을 병합 + const rawMergedData = { ...dataToInsert, ...item }; + + // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) + const validColumnNames = columnInfo.map((col) => col.column_name); + const mergedData: Record = {}; + + Object.keys(rawMergedData).forEach((columnName) => { + // 실제 테이블 컬럼인지 확인 + if (validColumnNames.includes(columnName)) { + const column = columnInfo.find((col) => col.column_name === columnName); + if (column) { + // 타입 변환 + mergedData[columnName] = this.convertValueForPostgreSQL( + rawMergedData[columnName], + column.data_type + ); + } else { + mergedData[columnName] = rawMergedData[columnName]; + } + } else { + console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`); + } + }); + + const mergedColumns = Object.keys(mergedData); + const mergedValues: any[] = Object.values(mergedData); + const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", "); + + let mergedUpsertQuery: string; + if (primaryKeys.length > 0) { + const conflictColumns = primaryKeys.join(", "); + const updateSet = mergedColumns + .filter((col) => !primaryKeys.includes(col)) + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + mergedUpsertQuery = updateSet + ? `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) + VALUES (${mergedPlaceholders}) + ON CONFLICT (${conflictColumns}) + DO UPDATE SET ${updateSet} + RETURNING *` + : `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) + VALUES (${mergedPlaceholders}) + ON CONFLICT (${conflictColumns}) + DO NOTHING + RETURNING *`; + } else { + mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) + VALUES (${mergedPlaceholders}) + RETURNING *`; + } + + console.log(`📝 병합 INSERT:`, { mergedData }); + + const itemResult = await transaction(async (client) => { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); + const res = await client.query(mergedUpsertQuery, mergedValues); + return res.rows[0]; + }); + + result.push(itemResult); + } + } + + console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`); + } else { + // 일반 모드: 헤더만 저장 + result = await transaction(async (client) => { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); + const res = await client.query(upsertQuery, values); + return res.rows; + }); + + console.log("✅ 서비스: 실제 테이블 저장 성공:", result); + } // 결과를 표준 형식으로 변환 const insertedRecord = Array.isArray(result) ? result[0] : result; - // 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장) - if (repeaterData.length > 0) { + // 📝 별도 테이블 Repeater 데이터 저장 + if (separateRepeaterData.length > 0) { console.log( - `🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater` + `🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개` ); - for (const repeater of repeaterData) { + for (const repeater of separateRepeaterData) { const targetTableName = repeater.targetTable || tableName; console.log( `📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장` @@ -497,8 +608,13 @@ export class DynamicFormService { created_by, updated_by, regdate: new Date(), + // 🔥 멀티테넌시: company_code 필수 추가 + company_code: data.company_code || company_code, }; + // 🔥 별도 테이블인 경우에만 외래키 추가 + // (같은 테이블이면 이미 병합 모드에서 처리됨) + // 대상 테이블에 존재하는 컬럼만 필터링 Object.keys(itemData).forEach((key) => { if (!targetColumnNames.includes(key)) { diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts new file mode 100644 index 00000000..7d969b06 --- /dev/null +++ b/backend-node/src/services/menuCopyService.ts @@ -0,0 +1,1861 @@ +import { PoolClient } from "pg"; +import { query, pool } from "../database/db"; +import logger from "../utils/logger"; + +/** + * 메뉴 복사 결과 + */ +export interface MenuCopyResult { + success: boolean; + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + copiedCategorySettings: number; + copiedNumberingRules: number; + menuIdMap: Record; + screenIdMap: Record; + flowIdMap: Record; + warnings: string[]; +} + +/** + * 메뉴 정보 + */ +interface Menu { + objid: number; + menu_type: number | null; + parent_obj_id: number | null; + menu_name_kor: string | null; + menu_name_eng: string | null; + seq: number | null; + menu_url: string | null; + menu_desc: string | null; + writer: string | null; + regdate: Date | null; + status: string | null; + system_name: string | null; + company_code: string | null; + lang_key: string | null; + lang_key_desc: string | null; + screen_code: string | null; + menu_code: string | null; +} + +/** + * 화면 정의 + */ +interface ScreenDefinition { + screen_id: number; + screen_name: string; + screen_code: string; + table_name: string; + company_code: string; + description: string | null; + is_active: string; + layout_metadata: any; + db_source_type: string | null; + db_connection_id: number | null; +} + +/** + * 화면 레이아웃 + */ +interface ScreenLayout { + layout_id: number; + screen_id: number; + component_type: string; + component_id: string; + parent_id: string | null; + position_x: number; + position_y: number; + width: number; + height: number; + properties: any; + display_order: number; + layout_type: string | null; + layout_config: any; + zones_config: any; + zone_id: string | null; +} + +/** + * 플로우 정의 + */ +interface FlowDefinition { + id: number; + name: string; + description: string | null; + table_name: string; + is_active: boolean; + company_code: string; + db_source_type: string | null; + db_connection_id: number | null; +} + +/** + * 플로우 스텝 + */ +interface FlowStep { + id: number; + flow_definition_id: number; + step_name: string; + step_order: number; + condition_json: any; + color: string | null; + position_x: number | null; + position_y: number | null; + table_name: string | null; + move_type: string | null; + status_column: string | null; + status_value: string | null; + target_table: string | null; + field_mappings: any; + required_fields: any; + integration_type: string | null; + integration_config: any; + display_config: any; +} + +/** + * 플로우 스텝 연결 + */ +interface FlowStepConnection { + id: number; + flow_definition_id: number; + from_step_id: number; + to_step_id: number; + label: string | null; +} + +/** + * 코드 카테고리 + */ +interface CodeCategory { + category_code: string; + category_name: string; + category_name_eng: string | null; + description: string | null; + sort_order: number | null; + is_active: string; + company_code: string; + menu_objid: number; +} + +/** + * 코드 정보 + */ +interface CodeInfo { + code_category: string; + code_value: string; + code_name: string; + code_name_eng: string | null; + description: string | null; + sort_order: number | null; + is_active: string; + company_code: string; + menu_objid: number; +} + +/** + * 메뉴 복사 서비스 + */ +export class MenuCopyService { + /** + * 메뉴 트리 수집 (재귀) + */ + private async collectMenuTree( + rootMenuObjid: number, + client: PoolClient + ): Promise { + logger.info(`📂 메뉴 트리 수집 시작: rootMenuObjid=${rootMenuObjid}`); + + const result: Menu[] = []; + const visited = new Set(); + const stack: number[] = [rootMenuObjid]; + + while (stack.length > 0) { + const currentObjid = stack.pop()!; + + if (visited.has(currentObjid)) continue; + visited.add(currentObjid); + + // 현재 메뉴 조회 + const menuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [currentObjid] + ); + + if (menuResult.rows.length === 0) { + logger.warn(`⚠️ 메뉴를 찾을 수 없음: objid=${currentObjid}`); + continue; + } + + const menu = menuResult.rows[0]; + result.push(menu); + + // 자식 메뉴 조회 + const childrenResult = await client.query( + `SELECT * FROM menu_info WHERE parent_obj_id = $1 ORDER BY seq`, + [currentObjid] + ); + + for (const child of childrenResult.rows) { + if (!visited.has(child.objid)) { + stack.push(child.objid); + } + } + } + + logger.info(`✅ 메뉴 트리 수집 완료: ${result.length}개`); + return result; + } + + /** + * 화면 레이아웃에서 참조 화면 추출 + */ + private extractReferencedScreens(layouts: ScreenLayout[]): number[] { + const referenced: number[] = []; + + for (const layout of layouts) { + const props = layout.properties; + + if (!props) continue; + + // 1) 모달 버튼 (숫자 또는 문자열) + if (props?.componentConfig?.action?.targetScreenId) { + const targetId = props.componentConfig.action.targetScreenId; + const numId = + typeof targetId === "number" ? targetId : parseInt(targetId); + if (!isNaN(numId)) { + referenced.push(numId); + } + } + + // 2) 조건부 컨테이너 (숫자 또는 문자열) + if ( + props?.componentConfig?.sections && + Array.isArray(props.componentConfig.sections) + ) { + for (const section of props.componentConfig.sections) { + if (section.screenId) { + const screenId = section.screenId; + const numId = + typeof screenId === "number" ? screenId : parseInt(screenId); + if (!isNaN(numId)) { + referenced.push(numId); + } + } + } + } + } + + return referenced; + } + + /** + * 화면 수집 (중복 제거, 재귀적 참조 추적) + */ + private async collectScreens( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise> { + logger.info( + `📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}` + ); + + const screenIds = new Set(); + const visited = new Set(); + + // 1) 메뉴에 직접 할당된 화면 + for (const menuObjid of menuObjids) { + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); + } + } + + logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); + + // 2) 화면 내부에서 참조되는 화면 (재귀) + const queue = Array.from(screenIds); + + while (queue.length > 0) { + const screenId = queue.shift()!; + + if (visited.has(screenId)) continue; + visited.add(screenId); + + // 화면 레이아웃 조회 + const layoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1`, + [screenId] + ); + + // 참조 화면 추출 + const referencedScreens = this.extractReferencedScreens( + layoutsResult.rows + ); + + if (referencedScreens.length > 0) { + logger.info( + ` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}` + ); + } + + for (const refId of referencedScreens) { + if (!screenIds.has(refId)) { + screenIds.add(refId); + queue.push(refId); + } + } + } + + logger.info(`✅ 화면 수집 완료: ${screenIds.size}개 (참조 포함)`); + return screenIds; + } + + /** + * 플로우 수집 + */ + private async collectFlows( + screenIds: Set, + client: PoolClient + ): Promise> { + logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); + + const flowIds = new Set(); + + for (const screenId of screenIds) { + const layoutsResult = await client.query( + `SELECT properties FROM screen_layouts WHERE screen_id = $1`, + [screenId] + ); + + for (const layout of layoutsResult.rows) { + const props = layout.properties; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId) { + flowIds.add(flowId); + } + } + } + + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + return flowIds; + } + + /** + * 코드 수집 + */ + private async collectCodes( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> { + logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`); + + const categories: CodeCategory[] = []; + const codes: CodeInfo[] = []; + + for (const menuObjid of menuObjids) { + // 코드 카테고리 + const catsResult = await client.query( + `SELECT * FROM code_category + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + categories.push(...catsResult.rows); + + // 각 카테고리의 코드 정보 + for (const cat of catsResult.rows) { + const codesResult = await client.query( + `SELECT * FROM code_info + WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`, + [cat.category_code, menuObjid, sourceCompanyCode] + ); + codes.push(...codesResult.rows); + } + } + + logger.info( + `✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개` + ); + return { categories, codes }; + } + + /** + * 카테고리 설정 수집 + */ + private async collectCategorySettings( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ + columnMappings: any[]; + categoryValues: any[]; + }> { + logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`); + + const columnMappings: any[] = []; + const categoryValues: any[] = []; + + // 카테고리 컬럼 매핑 (메뉴별 + 공통) + const mappingsResult = await client.query( + `SELECT * FROM category_column_mapping + WHERE (menu_objid = ANY($1) OR menu_objid = 0) + AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); + columnMappings.push(...mappingsResult.rows); + + // 테이블 컬럼 카테고리 값 (메뉴별 + 공통) + const valuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE (menu_objid = ANY($1) OR menu_objid = 0) + AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); + categoryValues.push(...valuesResult.rows); + + logger.info( + `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)` + ); + return { columnMappings, categoryValues }; + } + + /** + * 채번 규칙 수집 + */ + private async collectNumberingRules( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ + rules: any[]; + parts: any[]; + }> { + logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`); + + const rules: any[] = []; + const parts: any[] = []; + + for (const menuObjid of menuObjids) { + // 채번 규칙 + const rulesResult = await client.query( + `SELECT * FROM numbering_rules + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + rules.push(...rulesResult.rows); + + // 각 규칙의 파트 + for (const rule of rulesResult.rows) { + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2`, + [rule.rule_id, sourceCompanyCode] + ); + parts.push(...partsResult.rows); + } + } + + logger.info( + `✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개` + ); + return { rules, parts }; + } + + /** + * 다음 메뉴 objid 생성 + */ + private async getNextMenuObjid(client: PoolClient): Promise { + const result = await client.query<{ max_objid: string }>( + `SELECT COALESCE(MAX(objid), 0)::text as max_objid FROM menu_info` + ); + return parseInt(result.rows[0].max_objid, 10) + 1; + } + + /** + * 고유 화면 코드 생성 + */ + private async generateUniqueScreenCode( + targetCompanyCode: string, + client: PoolClient + ): Promise { + // {company_code}_{순번} 형식 + const prefix = targetCompanyCode === "*" ? "*" : targetCompanyCode; + + const result = await client.query<{ max_num: string }>( + `SELECT COALESCE( + MAX( + CASE + WHEN screen_code ~ '^${prefix}_[0-9]+$' + THEN CAST(SUBSTRING(screen_code FROM '${prefix}_([0-9]+)') AS INTEGER) + ELSE 0 + END + ), 0 + )::text as max_num + FROM screen_definitions + WHERE company_code = $1`, + [targetCompanyCode] + ); + + const maxNum = parseInt(result.rows[0].max_num, 10); + const newNum = maxNum + 1; + return `${prefix}_${String(newNum).padStart(3, "0")}`; + } + + /** + * properties 내부 참조 업데이트 + */ + /** + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + */ + private updateReferencesInProperties( + properties: any, + screenIdMap: Map, + flowIdMap: Map + ): any { + if (!properties) return properties; + + // 깊은 복사 + const updated = JSON.parse(JSON.stringify(properties)); + + // 재귀적으로 객체/배열 탐색 + this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + + return updated; + } + + /** + * 재귀적으로 모든 ID 참조 업데이트 + */ + private recursiveUpdateReferences( + obj: any, + screenIdMap: Map, + flowIdMap: Map, + path: string = "" + ): void { + if (!obj || typeof obj !== "object") return; + + // 배열인 경우 + if (Array.isArray(obj)) { + obj.forEach((item, index) => { + this.recursiveUpdateReferences( + item, + screenIdMap, + flowIdMap, + `${path}[${index}]` + ); + }); + return; + } + + // 객체인 경우 - 키별로 처리 + for (const key of Object.keys(obj)) { + const value = obj[key]; + const currentPath = path ? `${path}.${key}` : key; + + // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + if ( + key === "screen_id" || + key === "screenId" || + key === "targetScreenId" + ) { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue)) { + const newId = screenIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 + logger.info( + ` 🔗 화면 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } + } + } + + // flowId 매핑 (숫자 또는 숫자 문자열) + if (key === "flowId") { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue)) { + const newId = flowIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 + logger.debug( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } + } + } + + // 재귀 호출 + if (typeof value === "object" && value !== null) { + this.recursiveUpdateReferences( + value, + screenIdMap, + flowIdMap, + currentPath + ); + } + } + } + + /** + * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) + * + * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + */ + private async deleteExistingCopy( + sourceMenuObjid: number, + targetCompanyCode: string, + client: PoolClient + ): Promise { + logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); + + // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + const sourceMenuResult = await client.query( + `SELECT menu_name_kor, menu_name_eng + FROM menu_info + WHERE objid = $1`, + [sourceMenuObjid] + ); + + if (sourceMenuResult.rows.length === 0) { + logger.warn("⚠️ 원본 메뉴를 찾을 수 없습니다"); + return; + } + + const sourceMenu = sourceMenuResult.rows[0]; + + // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) + const existingMenuResult = await client.query<{ objid: number }>( + `SELECT objid + FROM menu_info + WHERE source_menu_objid = $1 + AND company_code = $2 + AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + [sourceMenuObjid, targetCompanyCode] + ); + + if (existingMenuResult.rows.length === 0) { + logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다"); + return; + } + + const existingMenuObjid = existingMenuResult.rows[0].objid; + logger.info( + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + ); + + // 3. 기존 메뉴 트리 수집 + const existingMenus = await this.collectMenuTree(existingMenuObjid, client); + const existingMenuIds = existingMenus.map((m) => m.objid); + + logger.info(`📊 삭제 대상: 메뉴 ${existingMenus.length}개`); + + // 4. 관련 화면 ID 수집 + const existingScreenIds = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + + const screenIds = existingScreenIds.rows.map((r) => r.screen_id); + + // 5. 삭제 순서 (외래키 제약 고려) + + // 5-1. 화면 레이아웃 삭제 + if (screenIds.length > 0) { + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + logger.info(` ✅ 화면 레이아웃 삭제 완료`); + } + + // 5-2. 화면-메뉴 할당 삭제 + await client.query( + `DELETE FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); + + // 5-3. 화면 정의 삭제 + if (screenIds.length > 0) { + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screenIds, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료`); + } + + // 5-4. 메뉴 권한 삭제 + await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ + existingMenuIds, + ]); + logger.info(` ✅ 메뉴 권한 삭제 완료`); + + // 5-5. 채번 규칙 파트 삭제 + await client.query( + `DELETE FROM numbering_rule_parts + WHERE rule_id IN ( + SELECT rule_id FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2 + )`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 채번 규칙 파트 삭제 완료`); + + // 5-6. 채번 규칙 삭제 + await client.query( + `DELETE FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 채번 규칙 삭제 완료`); + + // 5-7. 테이블 컬럼 카테고리 값 삭제 + await client.query( + `DELETE FROM table_column_category_values + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 카테고리 값 삭제 완료`); + + // 5-8. 카테고리 컬럼 매핑 삭제 + await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 카테고리 매핑 삭제 완료`); + + // 5-9. 메뉴 삭제 (역순: 하위 메뉴부터) + for (let i = existingMenus.length - 1; i >= 0; i--) { + await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ + existingMenus[i].objid, + ]); + } + logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); + + logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); + } + + /** + * 메뉴 복사 (메인 함수) + */ + async copyMenu( + sourceMenuObjid: number, + targetCompanyCode: string, + userId: string, + screenNameConfig?: { + removeText?: string; + addPrefix?: string; + } + ): Promise { + logger.info(` +🚀 ============================================ + 메뉴 복사 시작 + 원본 메뉴: ${sourceMenuObjid} + 대상 회사: ${targetCompanyCode} + 사용자: ${userId} +============================================ + `); + + const warnings: string[] = []; + const client = await pool.connect(); + + try { + // 트랜잭션 시작 + await client.query("BEGIN"); + logger.info("📦 트랜잭션 시작"); + + // === 0단계: 기존 복사본 삭제 (덮어쓰기) === + await this.deleteExistingCopy(sourceMenuObjid, targetCompanyCode, client); + + // === 1단계: 수집 (Collection Phase) === + logger.info("\n📂 [1단계] 데이터 수집"); + + const menus = await this.collectMenuTree(sourceMenuObjid, client); + const sourceCompanyCode = menus[0].company_code!; + + const screenIds = await this.collectScreens( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + const flowIds = await this.collectFlows(screenIds, client); + + const codes = await this.collectCodes( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + const categorySettings = await this.collectCategorySettings( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + const numberingRules = await this.collectNumberingRules( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + logger.info(` +📊 수집 완료: + - 메뉴: ${menus.length}개 + - 화면: ${screenIds.size}개 + - 플로우: ${flowIds.size}개 + - 코드 카테고리: ${codes.categories.length}개 + - 코드: ${codes.codes.length}개 + - 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개 + - 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개 + `); + + // === 2단계: 플로우 복사 === + logger.info("\n🔄 [2단계] 플로우 복사"); + const flowIdMap = await this.copyFlows( + flowIds, + targetCompanyCode, + userId, + client + ); + + // === 3단계: 화면 복사 === + logger.info("\n📄 [3단계] 화면 복사"); + const screenIdMap = await this.copyScreens( + screenIds, + targetCompanyCode, + flowIdMap, + userId, + client, + screenNameConfig + ); + + // === 4단계: 메뉴 복사 === + logger.info("\n📂 [4단계] 메뉴 복사"); + const menuIdMap = await this.copyMenus( + menus, + sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 + targetCompanyCode, + screenIdMap, + userId, + client + ); + + // === 5단계: 화면-메뉴 할당 === + logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + await this.createScreenMenuAssignments( + menus, + menuIdMap, + screenIdMap, + targetCompanyCode, + client + ); + + // === 6단계: 코드 복사 === + logger.info("\n📋 [6단계] 코드 복사"); + await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); + + // === 7단계: 카테고리 설정 복사 === + logger.info("\n📂 [7단계] 카테고리 설정 복사"); + await this.copyCategorySettings( + categorySettings, + menuIdMap, + targetCompanyCode, + userId, + client + ); + + // === 8단계: 채번 규칙 복사 === + logger.info("\n📋 [8단계] 채번 규칙 복사"); + await this.copyNumberingRules( + numberingRules, + menuIdMap, + targetCompanyCode, + userId, + client + ); + + // 커밋 + await client.query("COMMIT"); + logger.info("✅ 트랜잭션 커밋 완료"); + + const result: MenuCopyResult = { + success: true, + copiedMenus: menuIdMap.size, + copiedScreens: screenIdMap.size, + copiedFlows: flowIdMap.size, + copiedCategories: codes.categories.length, + copiedCodes: codes.codes.length, + copiedCategorySettings: + categorySettings.columnMappings.length + + categorySettings.categoryValues.length, + copiedNumberingRules: + numberingRules.rules.length + numberingRules.parts.length, + menuIdMap: Object.fromEntries(menuIdMap), + screenIdMap: Object.fromEntries(screenIdMap), + flowIdMap: Object.fromEntries(flowIdMap), + warnings, + }; + + logger.info(` +🎉 ============================================ + 메뉴 복사 완료! + - 메뉴: ${result.copiedMenus}개 + - 화면: ${result.copiedScreens}개 + - 플로우: ${result.copiedFlows}개 + - 코드 카테고리: ${result.copiedCategories}개 + - 코드: ${result.copiedCodes}개 + - 카테고리 설정: ${result.copiedCategorySettings}개 + - 채번 규칙: ${result.copiedNumberingRules}개 +============================================ + `); + + return result; + } catch (error: any) { + // 롤백 + await client.query("ROLLBACK"); + logger.error("❌ 메뉴 복사 실패, 롤백됨:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 플로우 복사 + */ + private async copyFlows( + flowIds: Set, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise> { + const flowIdMap = new Map(); + + if (flowIds.size === 0) { + logger.info("📭 복사할 플로우 없음"); + return flowIdMap; + } + + logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + + for (const originalFlowId of flowIds) { + try { + // 1) flow_definition 조회 + const flowDefResult = await client.query( + `SELECT * FROM flow_definition WHERE id = $1`, + [originalFlowId] + ); + + if (flowDefResult.rows.length === 0) { + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } + + const flowDef = flowDefResult.rows[0]; + + // 2) flow_definition 복사 + const newFlowResult = await client.query<{ id: number }>( + `INSERT INTO flow_definition ( + name, description, table_name, is_active, + company_code, created_by, db_source_type, db_connection_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + flowDef.name, + flowDef.description, + flowDef.table_name, + flowDef.is_active, + targetCompanyCode, // 새 회사 코드 + userId, + flowDef.db_source_type, + flowDef.db_connection_id, + ] + ); + + const newFlowId = newFlowResult.rows[0].id; + flowIdMap.set(originalFlowId, newFlowId); + + logger.info( + ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ); + + // 3) flow_step 복사 + const stepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, + [originalFlowId] + ); + + const stepIdMap = new Map(); + + for (const step of stepsResult.rows) { + const newStepResult = await client.query<{ id: number }>( + `INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, condition_json, + color, position_x, position_y, table_name, move_type, + status_column, status_value, target_table, field_mappings, + required_fields, integration_type, integration_config, display_config + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING id`, + [ + newFlowId, // 새 플로우 ID + step.step_name, + step.step_order, + step.condition_json, + step.color, + step.position_x, + step.position_y, + step.table_name, + step.move_type, + step.status_column, + step.status_value, + step.target_table, + step.field_mappings, + step.required_fields, + step.integration_type, + step.integration_config, + step.display_config, + ] + ); + + const newStepId = newStepResult.rows[0].id; + stepIdMap.set(step.id, newStepId); + } + + logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + + // 4) flow_step_connection 복사 (스텝 ID 재매핑) + const connectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, + [originalFlowId] + ); + + for (const conn of connectionsResult.rows) { + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); + + if (!newFromStepId || !newToStepId) { + logger.warn( + `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` + ); + continue; + } + + await client.query( + `INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) VALUES ($1, $2, $3, $4)`, + [newFlowId, newFromStepId, newToStepId, conn.label] + ); + } + + logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); + } catch (error: any) { + logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); + throw error; + } + } + + logger.info(`✅ 플로우 복사 완료: ${flowIdMap.size}개`); + return flowIdMap; + } + + /** + * 화면 복사 + */ + private async copyScreens( + screenIds: Set, + targetCompanyCode: string, + flowIdMap: Map, + userId: string, + client: PoolClient, + screenNameConfig?: { + removeText?: string; + addPrefix?: string; + } + ): Promise> { + const screenIdMap = new Map(); + + if (screenIds.size === 0) { + logger.info("📭 복사할 화면 없음"); + return screenIdMap; + } + + logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + + // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + const screenDefsToProcess: Array<{ + originalScreenId: number; + newScreenId: number; + screenDef: ScreenDefinition; + }> = []; + + for (const originalScreenId of screenIds) { + try { + // 1) screen_definitions 조회 + const screenDefResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = $1`, + [originalScreenId] + ); + + if (screenDefResult.rows.length === 0) { + logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); + continue; + } + + const screenDef = screenDefResult.rows[0]; + + // 2) 새 screen_code 생성 + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + // 2-1) 화면명 변환 적용 + let transformedScreenName = screenDef.screen_name; + if (screenNameConfig) { + // 1. 제거할 텍스트 제거 + if (screenNameConfig.removeText?.trim()) { + transformedScreenName = transformedScreenName.replace( + new RegExp(screenNameConfig.removeText.trim(), "g"), + "" + ); + transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + } + + // 2. 접두사 추가 + if (screenNameConfig.addPrefix?.trim()) { + transformedScreenName = + screenNameConfig.addPrefix.trim() + " " + transformedScreenName; + } + } + + // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING screen_id`, + [ + transformedScreenName, // 변환된 화면명 + newScreenCode, // 새 화면 코드 + screenDef.table_name, + targetCompanyCode, // 새 회사 코드 + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, // deleted_date: NULL (새 화면은 삭제되지 않음) + null, // deleted_by: NULL + null, // delete_reason: NULL + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + // 저장해서 2단계에서 처리 + screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + } catch (error: any) { + logger.error( + `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + error + ); + throw error; + } + } + + // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + logger.info( + `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + ); + + for (const { + originalScreenId, + newScreenId, + screenDef, + } of screenDefsToProcess) { + try { + // screen_layouts 복사 + const layoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); + + // 1단계: component_id 매핑 생성 (원본 → 새 ID) + const componentIdMap = new Map(); + for (const layout of layoutsResult.rows) { + const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + componentIdMap.set(layout.component_id, newComponentId); + } + + // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; + + // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; + + // properties 내부 참조 업데이트 + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap + ); + + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + [ + newScreenId, // 새 화면 ID + layout.component_type, + newComponentId, // 새 컴포넌트 ID + newParentId, // 매핑된 parent_id + layout.position_x, + layout.position_y, + layout.width, + layout.height, + updatedProperties, // 업데이트된 속성 + layout.display_order, + layout.layout_type, + layout.layout_config, + layout.zones_config, + newZoneId, // 매핑된 zone_id + ] + ); + } + + logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + } catch (error: any) { + logger.error( + `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + error + ); + throw error; + } + } + + logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + return screenIdMap; + } + + /** + * 메뉴 위상 정렬 (부모 먼저) + */ + private topologicalSortMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + const visited = new Set(); + const menuMap = new Map(); + + for (const menu of menus) { + menuMap.set(menu.objid, menu); + } + + const visit = (menu: Menu) => { + if (visited.has(menu.objid)) return; + + // 부모 먼저 방문 + if (menu.parent_obj_id) { + const parent = menuMap.get(menu.parent_obj_id); + if (parent) { + visit(parent); + } + } + + visited.add(menu.objid); + result.push(menu); + }; + + for (const menu of menus) { + visit(menu); + } + + return result; + } + + /** + * screen_code 재매핑 + */ + private getNewScreenCode( + screenIdMap: Map, + screenCode: string | null, + client: PoolClient + ): string | null { + if (!screenCode) return null; + + // screen_code로 screen_id 조회 (원본 회사) + // 간단하게 처리: 새 화면 코드는 이미 생성됨 + return screenCode; + } + + /** + * 메뉴 복사 + */ + private async copyMenus( + menus: Menu[], + rootMenuObjid: number, + targetCompanyCode: string, + screenIdMap: Map, + userId: string, + client: PoolClient + ): Promise> { + const menuIdMap = new Map(); + + if (menus.length === 0) { + logger.info("📭 복사할 메뉴 없음"); + return menuIdMap; + } + + logger.info(`📂 메뉴 복사 중: ${menus.length}개`); + + // 위상 정렬 (부모 먼저 삽입) + const sortedMenus = this.topologicalSortMenus(menus); + + for (const menu of sortedMenus) { + try { + // 새 objid 생성 + const newObjId = await this.getNextMenuObjid(client); + + // parent_obj_id 재매핑 + // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + let newParentObjId: number | null; + if (!menu.parent_obj_id || menu.parent_obj_id === 0) { + newParentObjId = 0; // 최상위 메뉴는 항상 0 + } else { + newParentObjId = + menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + } + + // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) + // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + const sourceMenuObjid = isRootMenu ? menu.objid : null; + + if (sourceMenuObjid) { + logger.info( + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` + ); + } + + // screen_code는 그대로 유지 (화면-메뉴 할당에서 처리) + await client.query( + `INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_url, menu_desc, writer, status, system_name, + company_code, lang_key, lang_key_desc, screen_code, menu_code, + source_menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [ + newObjId, + menu.menu_type, + newParentObjId, // 재매핑 + menu.menu_name_kor, + menu.menu_name_eng, + menu.seq, + menu.menu_url, + menu.menu_desc, + userId, + menu.status, + menu.system_name, + targetCompanyCode, // 새 회사 코드 + menu.lang_key, + menu.lang_key_desc, + menu.screen_code, // 그대로 유지 + menu.menu_code, + sourceMenuObjid, // 원본 메뉴 ID (최상위만) + ] + ); + + menuIdMap.set(menu.objid, newObjId); + + logger.info( + ` ✅ 메뉴 복사: ${menu.objid} → ${newObjId} (${menu.menu_name_kor})` + ); + } catch (error: any) { + logger.error(`❌ 메뉴 복사 실패: objid=${menu.objid}`, error); + throw error; + } + } + + logger.info(`✅ 메뉴 복사 완료: ${menuIdMap.size}개`); + return menuIdMap; + } + + /** + * 화면-메뉴 할당 + */ + private async createScreenMenuAssignments( + menus: Menu[], + menuIdMap: Map, + screenIdMap: Map, + targetCompanyCode: string, + client: PoolClient + ): Promise { + logger.info(`🔗 화면-메뉴 할당 중...`); + + let assignmentCount = 0; + + for (const menu of menus) { + const newMenuObjid = menuIdMap.get(menu.objid); + if (!newMenuObjid) continue; + + // 원본 메뉴에 할당된 화면 조회 + const assignmentsResult = await client.query<{ + screen_id: number; + display_order: number; + is_active: string; + }>( + `SELECT screen_id, display_order, is_active + FROM screen_menu_assignments + WHERE menu_objid = $1 AND company_code = $2`, + [menu.objid, menu.company_code] + ); + + for (const assignment of assignmentsResult.rows) { + const newScreenId = screenIdMap.get(assignment.screen_id); + if (!newScreenId) { + logger.warn( + `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` + ); + continue; + } + + // 새 할당 생성 + await client.query( + `INSERT INTO screen_menu_assignments ( + screen_id, menu_objid, company_code, display_order, is_active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + newScreenId, // 재매핑 + newMenuObjid, // 재매핑 + targetCompanyCode, + assignment.display_order, + assignment.is_active, + "system", + ] + ); + + assignmentCount++; + } + } + + logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + } + + /** + * 코드 카테고리 중복 체크 + */ + private async checkCodeCategoryExists( + categoryCode: string, + companyCode: string, + menuObjid: number, + client: PoolClient + ): Promise { + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM code_category + WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3 + ) as exists`, + [categoryCode, companyCode, menuObjid] + ); + return result.rows[0].exists; + } + + /** + * 코드 정보 중복 체크 + */ + private async checkCodeInfoExists( + categoryCode: string, + codeValue: string, + companyCode: string, + menuObjid: number, + client: PoolClient + ): Promise { + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM code_info + WHERE code_category = $1 AND code_value = $2 + AND company_code = $3 AND menu_objid = $4 + ) as exists`, + [categoryCode, codeValue, companyCode, menuObjid] + ); + return result.rows[0].exists; + } + + /** + * 코드 복사 + */ + private async copyCodes( + codes: { categories: CodeCategory[]; codes: CodeInfo[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 코드 복사 중...`); + + let categoryCount = 0; + let codeCount = 0; + let skippedCategories = 0; + let skippedCodes = 0; + + // 1) 코드 카테고리 복사 (중복 체크) + for (const category of codes.categories) { + const newMenuObjid = menuIdMap.get(category.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const exists = await this.checkCodeCategoryExists( + category.category_code, + targetCompanyCode, + newMenuObjid, + client + ); + + if (exists) { + skippedCategories++; + logger.debug( + ` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})` + ); + continue; + } + + // 카테고리 복사 + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + category.category_code, + category.category_name, + category.category_name_eng, + category.description, + category.sort_order, + category.is_active, + targetCompanyCode, // 새 회사 코드 + newMenuObjid, // 재매핑 + userId, + ] + ); + + categoryCount++; + } + + // 2) 코드 정보 복사 (중복 체크) + for (const code of codes.codes) { + const newMenuObjid = menuIdMap.get(code.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const exists = await this.checkCodeInfoExists( + code.code_category, + code.code_value, + targetCompanyCode, + newMenuObjid, + client + ); + + if (exists) { + skippedCodes++; + logger.debug( + ` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})` + ); + continue; + } + + // 코드 복사 + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + code.code_category, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + targetCompanyCode, // 새 회사 코드 + newMenuObjid, // 재매핑 + userId, + ] + ); + + codeCount++; + } + + logger.info( + `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` + ); + } + + /** + * 카테고리 설정 복사 + */ + private async copyCategorySettings( + settings: { columnMappings: any[]; categoryValues: any[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📂 카테고리 설정 복사 중...`); + + const valueIdMap = new Map(); // 원본 value_id → 새 value_id + let mappingCount = 0; + let valueCount = 0; + + // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) + for (const mapping of settings.columnMappings) { + // menu_objid = 0인 공통 설정은 그대로 0으로 유지 + let newMenuObjid: number | undefined; + + if ( + mapping.menu_objid === 0 || + mapping.menu_objid === "0" || + mapping.menu_objid == 0 + ) { + newMenuObjid = 0; // 공통 설정 + } else { + newMenuObjid = menuIdMap.get(mapping.menu_objid); + if (newMenuObjid === undefined) { + logger.debug( + ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}` + ); + continue; + } + } + + // 기존 매핑 삭제 (덮어쓰기) + await client.query( + `DELETE FROM category_column_mapping + WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`, + [mapping.table_name, mapping.physical_column_name, targetCompanyCode] + ); + + // 새 매핑 추가 + await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + mapping.table_name, + mapping.logical_column_name, + mapping.physical_column_name, + newMenuObjid, + targetCompanyCode, + mapping.description, + userId, + ] + ); + + mappingCount++; + } + + // 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지) + const sortedValues = settings.categoryValues.sort( + (a, b) => a.depth - b.depth + ); + + // 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위) + const uniqueTableColumns = new Set(); + for (const value of sortedValues) { + uniqueTableColumns.add(`${value.table_name}:${value.column_name}`); + } + + for (const tableColumn of uniqueTableColumns) { + const [tableName, columnName] = tableColumn.split(":"); + await client.query( + `DELETE FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, + [tableName, columnName, targetCompanyCode] + ); + logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`); + } + + // 새 값 추가 + for (const value of sortedValues) { + // menu_objid = 0인 공통 설정은 그대로 0으로 유지 + let newMenuObjid: number | undefined; + + if ( + value.menu_objid === 0 || + value.menu_objid === "0" || + value.menu_objid == 0 + ) { + newMenuObjid = 0; // 공통 설정 + } else { + newMenuObjid = menuIdMap.get(value.menu_objid); + if (newMenuObjid === undefined) { + logger.debug( + ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}` + ); + continue; + } + } + + // 부모 ID 재매핑 + let newParentValueId = null; + if (value.parent_value_id) { + newParentValueId = valueIdMap.get(value.parent_value_id) || null; + } + + const result = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, + value_order, parent_value_id, depth, description, + color, icon, is_active, is_default, + company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING value_id`, + [ + value.table_name, + value.column_name, + value.value_code, + value.value_label, + value.value_order, + newParentValueId, + value.depth, + value.description, + value.color, + value.icon, + value.is_active, + value.is_default, + targetCompanyCode, + newMenuObjid, + userId, + ] + ); + + // ID 매핑 저장 + const newValueId = result.rows[0].value_id; + valueIdMap.set(value.value_id, newValueId); + + valueCount++; + } + + logger.info( + `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)` + ); + } + + /** + * 채번 규칙 복사 + */ + private async copyNumberingRules( + rules: { rules: any[]; parts: any[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 채번 규칙 복사 중...`); + + const ruleIdMap = new Map(); // 원본 rule_id → 새 rule_id + let ruleCount = 0; + let partCount = 0; + + // 1) 채번 규칙 복사 + for (const rule of rules.rules) { + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (!newMenuObjid) continue; + + // 새 rule_id 생성 (타임스탬프 기반) + const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + ruleIdMap.set(rule.rule_id, newRuleId); + + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, + reset_period, current_sequence, table_name, column_name, + company_code, menu_objid, created_by, scope_type + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 1, // 시퀀스 초기화 + rule.table_name, + rule.column_name, + targetCompanyCode, + newMenuObjid, + userId, + rule.scope_type, + ] + ); + + ruleCount++; + } + + // 2) 채번 규칙 파트 복사 + for (const part of rules.parts) { + const newRuleId = ruleIdMap.get(part.rule_id); + if (!newRuleId) continue; + + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config, + part.manual_config, + targetCompanyCode, + ] + ); + + partCount++; + } + + logger.info( + `✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개` + ); + } +} diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 368559df..cb405b33 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -300,10 +300,9 @@ class NumberingRuleService { FROM numbering_rules WHERE scope_type = 'global' - OR scope_type = 'table' OR (scope_type = 'menu' AND menu_objid = ANY($1)) - OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함 + OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링 + OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 @@ -313,9 +312,9 @@ class NumberingRuleService { created_at DESC `; params = [siblingObjids]; - logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids }); + logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함) + // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) query = ` SELECT rule_id AS "ruleId", @@ -336,10 +335,9 @@ class NumberingRuleService { WHERE company_code = $1 AND ( scope_type = 'global' - OR scope_type = 'table' OR (scope_type = 'menu' AND menu_objid = ANY($2)) - OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함 + OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링 + OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) ) ORDER BY CASE @@ -350,7 +348,7 @@ class NumberingRuleService { created_at DESC `; params = [companyCode, siblingObjids]; - logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids }); + logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); } logger.info("🔍 채번 규칙 쿼리 실행", { diff --git a/backend-node/src/types/order.ts b/backend-node/src/types/order.ts new file mode 100644 index 00000000..30731b86 --- /dev/null +++ b/backend-node/src/types/order.ts @@ -0,0 +1,80 @@ +/** + * 수주 관리 타입 정의 + */ + +/** + * 수주 품목 (order_mng_sub) + */ +export interface OrderItem { + sub_objid: string; // 품목 고유 ID (예: ORD-20251121-051_1) + part_objid: string; // 품목 코드 + partner_price: number; // 단가 + partner_qty: number; // 수량 + delivery_date: string | null; // 납기일 + status: string; // 상태 + regdate: string; // 등록일 +} + +/** + * 수주 마스터 (order_mng_master) + */ +export interface OrderMaster { + order_no: string; // 수주 번호 (예: ORD-20251121-051) + partner_objid: string; // 거래처 코드 + final_delivery_date: string | null; // 최종 납품일 + reason: string | null; // 메모/사유 + status: string; // 상태 + reg_date: string; // 등록일 + writer: string; // 작성자 (userId|companyCode) +} + +/** + * 수주 + 품목 (API 응답) + */ +export interface OrderWithItems extends OrderMaster { + items: OrderItem[]; // 품목 목록 +} + +/** + * 수주 등록 요청 + */ +export interface CreateOrderRequest { + inputMode: string; // 입력 방식 + salesType?: string; // 판매 유형 (국내/해외) + priceType?: string; // 단가 방식 + customerCode: string; // 거래처 코드 + contactPerson?: string; // 담당자 + deliveryDestination?: string; // 납품처 + deliveryAddress?: string; // 납품장소 + deliveryDate?: string; // 납품일 + items: Array<{ + item_code?: string; // 품목 코드 + id?: string; // 품목 ID (item_code 대체) + quantity?: number; // 수량 + unit_price?: number; // 단가 + selling_price?: number; // 판매가 + amount?: number; // 금액 + delivery_date?: string; // 품목별 납기일 + }>; + memo?: string; // 메모 + tradeInfo?: { + // 해외 판매 시 + incoterms?: string; + paymentTerms?: string; + currency?: string; + portOfLoading?: string; + portOfDischarge?: string; + hsCode?: string; + }; +} + +/** + * 수주 등록 응답 + */ +export interface CreateOrderResponse { + orderNo: string; // 생성된 수주 번호 + masterObjid: string; // 마스터 ID + itemCount: number; // 품목 개수 + totalAmount: number; // 전체 금액 +} + diff --git a/db/migrations/README_1003.md b/db/migrations/README_1003.md new file mode 100644 index 00000000..629e2cb8 --- /dev/null +++ b/db/migrations/README_1003.md @@ -0,0 +1,184 @@ +# 마이그레이션 1003: source_menu_objid 추가 + +## 📋 개요 + +메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다. + +## 🎯 목적 + +### 이전 방식의 문제점 +- 메뉴 이름으로만 기존 복사본 판단 +- 같은 이름의 다른 메뉴도 삭제될 위험 +- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가 + +### 개선 후 +- 원본 메뉴 ID로 정확히 추적 +- 같은 원본에서 복사된 메뉴만 덮어쓰기 +- 수동 메뉴와 복사 메뉴 명확히 구분 + +## 🗄️ 스키마 변경 + +### 추가되는 컬럼 +```sql +ALTER TABLE menu_info +ADD COLUMN source_menu_objid BIGINT; +``` + +### 인덱스 +```sql +-- 단일 인덱스 +CREATE INDEX idx_menu_info_source_menu_objid +ON menu_info(source_menu_objid); + +-- 복합 인덱스 (회사별 검색 최적화) +CREATE INDEX idx_menu_info_source_company +ON menu_info(source_menu_objid, company_code); +``` + +## 📊 데이터 구조 + +### 복사된 메뉴의 source_menu_objid 값 + +| 메뉴 레벨 | source_menu_objid | 설명 | +|-----------|-------------------|------| +| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 | +| 하위 메뉴 | NULL | 최상위 메뉴만 추적 | +| 수동 생성 메뉴 | NULL | 복사가 아님 | + +### 예시 + +#### 원본 (COMPANY_7) +``` +- 사용자 (objid: 1762407678882) + └─ 영업관리 (objid: 1762421877772) + └─ 거래처관리 (objid: 1762421920304) +``` + +#### 복사본 (COMPANY_11) +``` +- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적 + └─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL) + └─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL) +``` + +## 🚀 실행 방법 + +### 로컬 PostgreSQL +```bash +psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql +``` + +### Docker 환경 +```bash +# 백엔드 컨테이너를 통해 실행 +docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql +``` + +### DBeaver / pgAdmin +1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기 +2. 전체 스크립트 실행 + +## ✅ 확인 방법 + +### 1. 컬럼 추가 확인 +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +**예상 결과**: +``` +column_name | data_type | is_nullable +-------------------|-----------|------------- +source_menu_objid | bigint | YES +``` + +### 2. 인덱스 생성 확인 +```sql +SELECT indexname, indexdef +FROM pg_indexes +WHERE tablename = 'menu_info' + AND indexname LIKE '%source%'; +``` + +**예상 결과**: +``` +indexname | indexdef +---------------------------------|---------------------------------- +idx_menu_info_source_menu_objid | CREATE INDEX ... +idx_menu_info_source_company | CREATE INDEX ... +``` + +### 3. 기존 데이터 확인 +```sql +-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터) +SELECT + COUNT(*) as total, + COUNT(source_menu_objid) as with_source +FROM menu_info; +``` + +**예상 결과**: +``` +total | with_source +------|------------- + 114 | 0 +``` + +## 🔄 롤백 (필요 시) + +```sql +-- 인덱스 삭제 +DROP INDEX IF EXISTS idx_menu_info_source_menu_objid; +DROP INDEX IF EXISTS idx_menu_info_source_company; + +-- 컬럼 삭제 +ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid; +``` + +## 📝 주의사항 + +1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다. +2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다. +3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다. + +## 🧪 테스트 시나리오 + +### 1. 첫 복사 (source_menu_objid 설정) +``` +원본: 사용자 (objid: 1762407678882, COMPANY_7) +복사: 사용자 (objid: 1763688215729, COMPANY_11) + source_menu_objid: 1762407678882 ✅ +``` + +### 2. 재복사 (정확한 덮어쓰기) +``` +복사 전 조회: + SELECT objid FROM menu_info + WHERE source_menu_objid = 1762407678882 + AND company_code = 'COMPANY_11' + → 1763688215729 발견 + +동작: + 1. objid=1763688215729의 메뉴 트리 전체 삭제 + 2. 새로 복사 (source_menu_objid: 1762407678882) +``` + +### 3. 다른 메뉴는 영향 없음 +``` +수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11) + source_menu_objid: NULL ✅ + +"사용자" 메뉴 재복사 시: + → 관리자 메뉴는 그대로 유지 ✅ +``` + +## 📚 관련 파일 + +- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` +- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts` + - `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기 + - `copyMenus()`: 복사 시 source_menu_objid 저장 + diff --git a/db/migrations/RUN_MIGRATION_1003.md b/db/migrations/RUN_MIGRATION_1003.md new file mode 100644 index 00000000..6b33bafd --- /dev/null +++ b/db/migrations/RUN_MIGRATION_1003.md @@ -0,0 +1,146 @@ +# 마이그레이션 1003 실행 가이드 + +## ❌ 현재 에러 +``` +column "source_menu_objid" does not exist +``` + +**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음 + +## ✅ 해결 방법 + +### 방법 1: psql 직접 실행 (권장) + +```bash +# 1. PostgreSQL 접속 정보 확인 +# - Host: localhost (또는 실제 DB 호스트) +# - Port: 5432 (기본값) +# - Database: ilshin +# - User: postgres + +# 2. 마이그레이션 실행 +cd /Users/kimjuseok/ERP-node +psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql + +# 또는 대화형으로 +psql -h localhost -U postgres -d ilshin +# 그 다음 파일 내용 붙여넣기 +``` + +### 방법 2: DBeaver / pgAdmin에서 실행 + +1. DBeaver 또는 pgAdmin 실행 +2. `ilshin` 데이터베이스 연결 +3. SQL 편집기 열기 +4. 아래 SQL 복사하여 실행: + +```sql +-- source_menu_objid 컬럼 추가 +ALTER TABLE menu_info +ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT; + +-- 인덱스 생성 (검색 성능 향상) +CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid +ON menu_info(source_menu_objid); + +-- 복합 인덱스: 회사별 원본 메뉴 검색 +CREATE INDEX IF NOT EXISTS idx_menu_info_source_company +ON menu_info(source_menu_objid, company_code); + +-- 컬럼 설명 추가 +COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)'; + +-- 확인 +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +### 방법 3: Docker를 통한 실행 + +Docker Compose 설정 확인 후: + +```bash +# Docker Compose에 DB 서비스가 있는 경우 +docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql + +# 또는 SQL을 직접 실행 +docker-compose exec db psql -U postgres -d ilshin -c " +ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT; +CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid); +CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code); +" +``` + +## ✅ 실행 후 확인 + +### 1. 컬럼이 추가되었는지 확인 +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +**예상 결과**: +``` +column_name | data_type | is_nullable +-------------------|-----------|------------- +source_menu_objid | bigint | YES +``` + +### 2. 인덱스 확인 +```sql +SELECT indexname +FROM pg_indexes +WHERE tablename = 'menu_info' + AND indexname LIKE '%source%'; +``` + +**예상 결과**: +``` +indexname +--------------------------------- +idx_menu_info_source_menu_objid +idx_menu_info_source_company +``` + +### 3. 메뉴 복사 재시도 +마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요. + +## 🔍 DB 접속 정보 찾기 + +### 환경 변수 확인 +```bash +# .env 파일 확인 +cat backend-node/.env | grep DB + +# Docker Compose 확인 +cat docker-compose*.yml | grep -A 10 postgres +``` + +### 일반적인 접속 정보 +- **Host**: localhost 또는 127.0.0.1 +- **Port**: 5432 (기본값) +- **Database**: ilshin +- **User**: postgres +- **Password**: (환경 설정 파일에서 확인) + +## ⚠️ 주의사항 + +1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장 +2. **권한 확인**: ALTER TABLE 권한이 필요합니다 +3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다 + +## 📞 문제 해결 + +### "permission denied" 에러 +→ postgres 사용자 또는 superuser 권한으로 실행 필요 + +### "relation does not exist" 에러 +→ 올바른 데이터베이스(ilshin)에 접속했는지 확인 + +### "already exists" 에러 +→ 이미 실행됨. 무시하고 진행 가능 + diff --git a/db/scripts/README_cleanup.md b/db/scripts/README_cleanup.md new file mode 100644 index 00000000..ecd7879f --- /dev/null +++ b/db/scripts/README_cleanup.md @@ -0,0 +1,126 @@ +# COMPANY_11 테스트 데이터 정리 가이드 + +## 📋 개요 + +메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다. + +## ⚠️ 중요 사항 + +- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`) +- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드 +- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능 +- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT + +## 🚀 실행 방법 + +### 방법 1: Docker 컨테이너에서 직접 실행 (권장) + +```bash +# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨) +cd /Users/kimjuseok/ERP-node +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql + +# 2. 실제 삭제 실행 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql +``` + +### 방법 2: DBeaver 또는 pgAdmin에서 실행 + +1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기 +2. 전체 스크립트 실행 (롤백되어 안전) +3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행 + +### 방법 3: psql 직접 접속 + +```bash +# 1. 컨테이너 접속 +docker exec -it erp-node-db-1 psql -U postgres -d ilshin + +# 2. SQL 복사 붙여넣기 +# (cleanup_company_11_execute.sql 내용 복사) +``` + +## 📊 삭제 대상 + +| 항목 | 테이블명 | 삭제 여부 | +|------|----------|-----------| +| 메뉴 | `menu_info` | ✅ 삭제 | +| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 | +| 화면 정의 | `screen_definitions` | ✅ 삭제 | +| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 | +| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 | +| 플로우 정의 | `flow_definition` | ✅ 삭제 | +| 플로우 스텝 | `flow_step` | ✅ 삭제 | +| 플로우 연결 | `flow_step_connection` | ✅ 삭제 | +| 코드 카테고리 | `code_category` | ✅ 삭제 | +| 코드 정보 | `code_info` | ✅ 삭제 | +| **권한 그룹** | `authority_master` | ❌ **보존** | +| **권한 멤버** | `authority_sub_user` | ❌ **보존** | +| **사용자** | `user_info` | ❌ **보존** | + +## 🔍 삭제 순서 (외래키 제약 고려) + +``` +1. screen_layouts (화면 레이아웃) +2. screen_menu_assignments (화면-메뉴 할당) +3. screen_definitions (화면 정의) +4. rel_menu_auth (메뉴 권한) +5. menu_info (메뉴) +6. flow_step (플로우 스텝) +7. flow_step_connection (플로우 연결) +8. flow_definition (플로우 정의) +9. code_info (코드 정보) +10. code_category (코드 카테고리) +``` + +## ✅ 실행 후 확인 + +스크립트 실행 후 다음과 같이 표시됩니다: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ 삭제 완료! + +남은 데이터: + - 메뉴: 0 개 + - 화면: 0 개 + - 권한 그룹: 1 개 (보존됨) + - 사용자: 1 개 (보존됨) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✨ 정리 완료! 메뉴 복사 테스트 준비됨 +``` + +## 🧪 테스트 시나리오 + +1. **데이터 정리** + ```bash + docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql + ``` + +2. **메뉴 복사 실행** + - 프론트엔드에서 원본 메뉴 선택 + - "복사" 버튼 클릭 + - 대상 회사: COMPANY_11 선택 + - 복사 실행 + +3. **복사 결과 확인** + - COMPANY_11 사용자(copy)로 로그인 + - 사용자 메뉴에 복사된 메뉴 표시 확인 + - 버튼 클릭 시 모달 화면 정상 열림 확인 + - 플로우 기능 정상 작동 확인 + +## 🔄 재테스트 + +재테스트가 필요하면 다시 정리 스크립트를 실행하세요: + +```bash +# 빠른 재테스트 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql +``` + +## 📝 참고 + +- **백업**: 중요한 데이터가 있다면 먼저 백업하세요 +- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다 +- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다 + diff --git a/docs/메뉴_복사_기능_구현_계획서.md b/docs/메뉴_복사_기능_구현_계획서.md new file mode 100644 index 00000000..a53a5704 --- /dev/null +++ b/docs/메뉴_복사_기능_구현_계획서.md @@ -0,0 +1,1660 @@ +# 메뉴 복사 기능 구현 계획서 + +## 📋 목차 +1. [개요](#개요) +2. [요구사항](#요구사항) +3. [데이터베이스 구조 분석](#데이터베이스-구조-분석) +4. [복사 대상 항목](#복사-대상-항목) +5. [복사 알고리즘](#복사-알고리즘) +6. [구현 단계](#구현-단계) +7. [API 명세](#api-명세) +8. [UI/UX 설계](#uiux-설계) +9. [예외 처리](#예외-처리) +10. [테스트 계획](#테스트-계획) + +--- + +## 개요 + +### 목적 +메뉴관리 화면에서 **복사 버튼 하나**로 선택된 메뉴와 관련된 모든 리소스를 다른 회사로 복사하여, 복사 즉시 해당 회사에서 사용 가능하도록 합니다. + +### 핵심 기능 +- 메뉴 트리 구조 복사 (부모-자식 관계 유지) +- 화면 + 레이아웃 복사 (모달, 조건부 컨테이너 포함) +- 플로우 제어 복사 (스텝, 연결, 조건) +- 코드 카테고리 + 코드 정보 복사 +- 중복 화면 자동 제거 +- 참조 관계 자동 재매핑 +- company_code 자동 변경 + +--- + +## 요구사항 + +### 기능 요구사항 + +#### FR-1: 메뉴 복사 +- **설명**: 선택된 메뉴와 하위 메뉴를 모두 복사 +- **입력**: 원본 메뉴 objid, 대상 회사 company_code +- **출력**: 복사된 메뉴 목록 +- **제약**: 메뉴 계층 구조 유지 + +#### FR-2: 화면 복사 +- **설명**: 메뉴에 할당된 모든 화면 복사 +- **입력**: 메뉴 objid 목록 +- **출력**: 복사된 화면 목록 +- **제약**: 중복 화면은 하나만 복사 + +#### FR-3: 화면 내부 참조 추적 +- **설명**: 화면 레이아웃에서 참조되는 화면들을 재귀적으로 추적 +- **대상**: + - 모달 버튼의 targetScreenId + - 조건부 컨테이너의 sections[].screenId + - 모달 안의 모달 (중첩 구조) +- **제약**: 무한 루프 방지 (이미 방문한 화면 체크) + +#### FR-4: 플로우 복사 +- **설명**: 화면에서 참조되는 플로우를 모두 복사 +- **대상**: + - flow_definition (플로우 정의) + - flow_step (스텝) + - flow_step_connection (스텝 간 연결) +- **제약**: 스텝 ID 재매핑 + +#### FR-5: 코드 복사 +- **설명**: 메뉴에 연결된 코드 카테고리와 코드 복사 +- **대상**: + - code_category (menu_objid 기준) + - code_info (menu_objid 기준) +- **제약**: 중복 카테고리 병합 + +#### FR-6: 참조 ID 재매핑 +- **설명**: 복사된 리소스의 ID를 원본 ID에서 새 ID로 자동 변경 +- **대상**: + - screen_id (화면 ID) + - flow_id (플로우 ID) + - menu_objid (메뉴 ID) + - step_id (스텝 ID) +- **방법**: ID 매핑 테이블 사용 + +### 비기능 요구사항 + +#### NFR-1: 성능 +- 복사 시간: 메뉴 100개 기준 2분 이내 +- 트랜잭션: 전체 작업을 하나의 트랜잭션으로 처리 + +#### NFR-2: 신뢰성 +- 실패 시 롤백: 일부만 복사되는 것 방지 +- 중복 실행 방지: 같은 요청 중복 처리 방지 + +#### NFR-3: 사용성 +- 진행 상황 표시: 실시간 복사 진행률 표시 +- 결과 보고서: 복사된 항목 상세 리스트 + +--- + +## 데이터베이스 구조 분석 + +### 주요 테이블 및 관계 + +```sql +-- 1. 메뉴 (계층 구조) +menu_info + ├─ objid (PK) - 메뉴 고유 ID + ├─ parent_obj_id - 부모 메뉴 ID + ├─ company_code (FK) - 회사 코드 + └─ screen_code - 할당된 화면 코드 + +-- 2. 화면 정의 +screen_definitions + ├─ screen_id (PK) - 화면 고유 ID + ├─ screen_code (UNIQUE) - 화면 코드 + ├─ company_code (FK) - 회사 코드 + ├─ table_name - 연결된 테이블 + └─ layout_metadata (JSONB) - 레이아웃 메타데이터 + +-- 3. 화면 레이아웃 (컴포넌트) +screen_layouts + ├─ layout_id (PK) + ├─ screen_id (FK) - 화면 ID + ├─ component_type - 컴포넌트 타입 + ├─ properties (JSONB) - 컴포넌트 속성 + │ ├─ componentConfig.action.targetScreenId (모달 참조) + │ ├─ sections[].screenId (조건부 컨테이너) + │ └─ dataflowConfig.flowConfig.flowId (플로우 참조) + └─ parent_id - 부모 컴포넌트 ID + +-- 4. 화면-메뉴 할당 +screen_menu_assignments + ├─ assignment_id (PK) + ├─ screen_id (FK) - 화면 ID + ├─ menu_objid (FK) - 메뉴 ID + └─ company_code (FK) - 회사 코드 + +-- 5. 플로우 정의 +flow_definition + ├─ id (PK) - 플로우 ID + ├─ name - 플로우 이름 + ├─ table_name - 연결된 테이블 + └─ company_code (FK) - 회사 코드 + +-- 6. 플로우 스텝 +flow_step + ├─ id (PK) - 스텝 ID + ├─ flow_definition_id (FK) - 플로우 ID + ├─ step_name - 스텝 이름 + ├─ step_order - 순서 + ├─ condition_json (JSONB) - 조건 + └─ integration_config (JSONB) - 통합 설정 + +-- 7. 플로우 스텝 연결 +flow_step_connection + ├─ id (PK) + ├─ flow_definition_id (FK) - 플로우 ID + ├─ from_step_id (FK) - 출발 스텝 ID + ├─ to_step_id (FK) - 도착 스텝 ID + └─ label - 연결 라벨 + +-- 8. 코드 카테고리 +code_category + ├─ category_code (PK) + ├─ company_code (PK, FK) + ├─ menu_objid (PK, FK) - 메뉴 ID + ├─ category_name - 카테고리 이름 + └─ description - 설명 + +-- 9. 코드 정보 +code_info + ├─ code_category (PK, FK) + ├─ company_code (PK, FK) + ├─ menu_objid (PK, FK) + ├─ code_value (PK) - 코드 값 + ├─ code_name - 코드 이름 + └─ description - 설명 +``` + +### 외래키 제약조건 + +```sql +-- 중요: 삽입 순서 고려 필요 +1. company_mng (회사 정보) - 먼저 존재해야 함 +2. menu_info (메뉴) +3. screen_definitions (화면) +4. flow_definition (플로우) +5. screen_layouts (레이아웃) +6. screen_menu_assignments (화면-메뉴 할당) +7. flow_step (플로우 스텝) +8. flow_step_connection (스텝 연결) +9. code_category (코드 카테고리) +10. code_info (코드 정보) +``` + +--- + +## 복사 대상 항목 + +### 1단계: 메뉴 트리 수집 + +```typescript +// 재귀적으로 하위 메뉴 수집 +function collectMenuTree(rootMenuObjid: number): Menu[] { + const result: Menu[] = []; + const stack: number[] = [rootMenuObjid]; + + while (stack.length > 0) { + const currentObjid = stack.pop()!; + const menu = getMenuByObjid(currentObjid); + result.push(menu); + + // 자식 메뉴들을 스택에 추가 + const children = getChildMenus(currentObjid); + stack.push(...children.map(c => c.objid)); + } + + return result; +} +``` + +**수집 항목**: +- 원본 메뉴 objid +- 하위 메뉴 objid 목록 (재귀) +- 부모-자식 관계 매핑 + +### 2단계: 화면 수집 (중복 제거) + +```typescript +// 메뉴에 할당된 화면 + 참조 화면 수집 +function collectScreens(menuObjids: number[]): Set { + const screenIds = new Set(); + const visited = new Set(); // 무한 루프 방지 + + // 1) 메뉴에 직접 할당된 화면 + for (const menuObjid of menuObjids) { + const assignments = getScreenMenuAssignments(menuObjid); + assignments.forEach(a => screenIds.add(a.screen_id)); + } + + // 2) 화면 내부에서 참조되는 화면 (재귀) + const queue = Array.from(screenIds); + while (queue.length > 0) { + const screenId = queue.shift()!; + if (visited.has(screenId)) continue; + visited.add(screenId); + + const referencedScreens = extractReferencedScreens(screenId); + referencedScreens.forEach(refId => { + if (!screenIds.has(refId)) { + screenIds.add(refId); + queue.push(refId); + } + }); + } + + return screenIds; +} + +// 화면 레이아웃에서 참조 화면 추출 +function extractReferencedScreens(screenId: number): number[] { + const layouts = getScreenLayouts(screenId); + const referenced: number[] = []; + + for (const layout of layouts) { + const props = layout.properties; + + // 모달 버튼 + if (props?.componentConfig?.action?.targetScreenId) { + referenced.push(props.componentConfig.action.targetScreenId); + } + + // 조건부 컨테이너 + if (props?.sections) { + for (const section of props.sections) { + if (section.screenId) { + referenced.push(section.screenId); + } + } + } + } + + return referenced; +} +``` + +**수집 항목**: +- 직접 할당 화면 ID 목록 +- 모달 참조 화면 ID 목록 +- 조건부 컨테이너 내 화면 ID 목록 +- 중복 제거된 최종 화면 ID Set + +### 3단계: 플로우 수집 + +```typescript +// 화면에서 참조되는 플로우 수집 +function collectFlows(screenIds: Set): Set { + const flowIds = new Set(); + + for (const screenId of screenIds) { + const layouts = getScreenLayouts(screenId); + + for (const layout of layouts) { + const flowId = layout.properties?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId) { + flowIds.add(flowId); + } + } + } + + return flowIds; +} +``` + +**수집 항목**: +- flow_definition.id 목록 +- 각 플로우의 flow_step 목록 +- 각 플로우의 flow_step_connection 목록 + +### 4단계: 코드 수집 + +```typescript +// 메뉴에 연결된 코드 수집 +function collectCodes(menuObjids: number[], companyCode: string): { + categories: CodeCategory[]; + codes: CodeInfo[]; +} { + const categories: CodeCategory[] = []; + const codes: CodeInfo[] = []; + + for (const menuObjid of menuObjids) { + // 코드 카테고리 + const cats = getCodeCategories(menuObjid, companyCode); + categories.push(...cats); + + // 각 카테고리의 코드 정보 + for (const cat of cats) { + const infos = getCodeInfos(cat.category_code, menuObjid, companyCode); + codes.push(...infos); + } + } + + return { categories, codes }; +} +``` + +**수집 항목**: +- code_category 목록 (menu_objid 기준) +- code_info 목록 (menu_objid + category_code 기준) + +--- + +## 복사 알고리즘 + +### 전체 프로세스 + +```typescript +async function copyMenu( + sourceMenuObjid: number, + targetCompanyCode: string, + userId: string +): Promise { + + // 트랜잭션 시작 + const client = await pool.connect(); + await client.query('BEGIN'); + + try { + // 1단계: 수집 (Collection Phase) + const menus = collectMenuTree(sourceMenuObjid); + const screenIds = collectScreens(menus.map(m => m.objid)); + const flowIds = collectFlows(screenIds); + const codes = collectCodes(menus.map(m => m.objid), menus[0].company_code); + + // 2단계: 플로우 복사 (Flow Copy Phase) + const flowIdMap = await copyFlows(flowIds, targetCompanyCode, userId, client); + + // 3단계: 화면 복사 (Screen Copy Phase) + const screenIdMap = await copyScreens( + screenIds, + targetCompanyCode, + flowIdMap, // 플로우 ID 재매핑 + userId, + client + ); + + // 4단계: 메뉴 복사 (Menu Copy Phase) + const menuIdMap = await copyMenus( + menus, + targetCompanyCode, + screenIdMap, // 화면 ID 재매핑 + userId, + client + ); + + // 5단계: 화면-메뉴 할당 (Assignment Phase) + await createScreenMenuAssignments( + menus, + menuIdMap, + screenIdMap, + targetCompanyCode, + client + ); + + // 6단계: 코드 복사 (Code Copy Phase) + await copyCodes( + codes, + menuIdMap, + targetCompanyCode, + userId, + client + ); + + // 커밋 + await client.query('COMMIT'); + + return { + success: true, + copiedMenus: Object.values(menuIdMap).length, + copiedScreens: Object.values(screenIdMap).length, + copiedFlows: Object.values(flowIdMap).length, + copiedCategories: codes.categories.length, + copiedCodes: codes.codes.length, + }; + + } catch (error) { + // 롤백 + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} +``` + +### 플로우 복사 알고리즘 + +```typescript +async function copyFlows( + flowIds: Set, + targetCompanyCode: string, + userId: string, + client: PoolClient +): Promise> { + + const flowIdMap = new Map(); // 원본 ID → 새 ID + + for (const originalFlowId of flowIds) { + // 1) flow_definition 복사 + const flowDef = await getFlowDefinition(originalFlowId, client); + const newFlowId = await insertFlowDefinition({ + ...flowDef, + company_code: targetCompanyCode, + created_by: userId, + }, client); + + flowIdMap.set(originalFlowId, newFlowId); + + // 2) flow_step 복사 + const steps = await getFlowSteps(originalFlowId, client); + const stepIdMap = new Map(); // 스텝 ID 매핑 + + for (const step of steps) { + const newStepId = await insertFlowStep({ + ...step, + flow_definition_id: newFlowId, // 새 플로우 ID + }, client); + + stepIdMap.set(step.id, newStepId); + } + + // 3) flow_step_connection 복사 (스텝 ID 재매핑) + const connections = await getFlowStepConnections(originalFlowId, client); + + for (const conn of connections) { + await insertFlowStepConnection({ + flow_definition_id: newFlowId, + from_step_id: stepIdMap.get(conn.from_step_id)!, // 재매핑 + to_step_id: stepIdMap.get(conn.to_step_id)!, // 재매핑 + label: conn.label, + }, client); + } + } + + return flowIdMap; +} +``` + +### 화면 복사 알고리즘 + +```typescript +async function copyScreens( + screenIds: Set, + targetCompanyCode: string, + flowIdMap: Map, // 플로우 ID 재매핑 + userId: string, + client: PoolClient +): Promise> { + + const screenIdMap = new Map(); // 원본 ID → 새 ID + + for (const originalScreenId of screenIds) { + // 1) screen_definitions 복사 + const screenDef = await getScreenDefinition(originalScreenId, client); + + // 새 screen_code 생성 (중복 방지) + const newScreenCode = await generateUniqueScreenCode(targetCompanyCode, client); + + const newScreenId = await insertScreenDefinition({ + ...screenDef, + screen_code: newScreenCode, + company_code: targetCompanyCode, + created_by: userId, + }, client); + + screenIdMap.set(originalScreenId, newScreenId); + + // 2) screen_layouts 복사 + const layouts = await getScreenLayouts(originalScreenId, client); + + for (const layout of layouts) { + // properties 내부 참조 업데이트 + const updatedProperties = updateReferencesInProperties( + layout.properties, + screenIdMap, // 화면 ID 재매핑 + flowIdMap // 플로우 ID 재매핑 + ); + + await insertScreenLayout({ + screen_id: newScreenId, // 새 화면 ID + component_type: layout.component_type, + component_id: layout.component_id, + parent_id: layout.parent_id, + position_x: layout.position_x, + position_y: layout.position_y, + width: layout.width, + height: layout.height, + properties: updatedProperties, // 업데이트된 속성 + display_order: layout.display_order, + layout_type: layout.layout_type, + layout_config: layout.layout_config, + zones_config: layout.zones_config, + zone_id: layout.zone_id, + }, client); + } + } + + return screenIdMap; +} + +// properties 내부 참조 업데이트 +function updateReferencesInProperties( + properties: any, + screenIdMap: Map, + flowIdMap: Map +): any { + + if (!properties) return properties; + + const updated = JSON.parse(JSON.stringify(properties)); // 깊은 복사 + + // 1) 모달 버튼의 targetScreenId + if (updated?.componentConfig?.action?.targetScreenId) { + const oldId = updated.componentConfig.action.targetScreenId; + const newId = screenIdMap.get(oldId); + if (newId) { + updated.componentConfig.action.targetScreenId = newId; + } + } + + // 2) 조건부 컨테이너의 sections[].screenId + if (updated?.sections) { + for (const section of updated.sections) { + if (section.screenId) { + const oldId = section.screenId; + const newId = screenIdMap.get(oldId); + if (newId) { + section.screenId = newId; + } + } + } + } + + // 3) 플로우 제어의 flowId + if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { + const oldId = updated.webTypeConfig.dataflowConfig.flowConfig.flowId; + const newId = flowIdMap.get(oldId); + if (newId) { + updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; + } + } + + return updated; +} +``` + +### 메뉴 복사 알고리즘 + +```typescript +async function copyMenus( + menus: Menu[], + targetCompanyCode: string, + screenIdMap: Map, + userId: string, + client: PoolClient +): Promise> { + + const menuIdMap = new Map(); // 원본 objid → 새 objid + + // 1) 메뉴를 깊이 순으로 정렬 (부모 먼저 삽입) + const sortedMenus = topologicalSortMenus(menus); + + for (const menu of sortedMenus) { + // screen_code 업데이트 (화면 ID 재매핑) + const newScreenCode = menu.screen_code + ? getNewScreenCode(screenIdMap, menu.screen_code) + : null; + + // parent_obj_id 업데이트 (메뉴 ID 재매핑) + const newParentObjId = menu.parent_obj_id + ? menuIdMap.get(menu.parent_obj_id) || null + : null; + + // 새 objid 생성 + const newObjId = await getNextMenuObjid(client); + + await insertMenu({ + objid: newObjId, + menu_type: menu.menu_type, + parent_obj_id: newParentObjId, // 재매핑 + menu_name_kor: menu.menu_name_kor, + menu_name_eng: menu.menu_name_eng, + seq: menu.seq, + menu_url: menu.menu_url, + menu_desc: menu.menu_desc, + writer: userId, + status: menu.status, + system_name: menu.system_name, + company_code: targetCompanyCode, // 새 회사 코드 + lang_key: menu.lang_key, + lang_key_desc: menu.lang_key_desc, + screen_code: newScreenCode, // 재매핑 + menu_code: menu.menu_code, + }, client); + + menuIdMap.set(menu.objid, newObjId); + } + + return menuIdMap; +} + +// 위상 정렬 (부모 먼저) +function topologicalSortMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + const visited = new Set(); + + function visit(menu: Menu) { + if (visited.has(menu.objid)) return; + + // 부모 먼저 방문 + if (menu.parent_obj_id) { + const parent = menus.find(m => m.objid === menu.parent_obj_id); + if (parent) visit(parent); + } + + visited.add(menu.objid); + result.push(menu); + } + + for (const menu of menus) { + visit(menu); + } + + return result; +} +``` + +### 코드 복사 알고리즘 + +```typescript +async function copyCodes( + codes: { categories: CodeCategory[]; codes: CodeInfo[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient +): Promise { + + // 1) 코드 카테고리 복사 (중복 체크) + for (const category of codes.categories) { + const newMenuObjid = menuIdMap.get(category.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크: 같은 category_code + company_code + menu_objid + const exists = await checkCodeCategoryExists( + category.category_code, + targetCompanyCode, + newMenuObjid, + client + ); + + if (!exists) { + await insertCodeCategory({ + category_code: category.category_code, + category_name: category.category_name, + category_name_eng: category.category_name_eng, + description: category.description, + sort_order: category.sort_order, + is_active: category.is_active, + company_code: targetCompanyCode, // 새 회사 코드 + menu_objid: newMenuObjid, // 재매핑 + created_by: userId, + }, client); + } + } + + // 2) 코드 정보 복사 (중복 체크) + for (const code of codes.codes) { + const newMenuObjid = menuIdMap.get(code.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크: 같은 code_category + code_value + company_code + menu_objid + const exists = await checkCodeInfoExists( + code.code_category, + code.code_value, + targetCompanyCode, + newMenuObjid, + client + ); + + if (!exists) { + await insertCodeInfo({ + code_category: code.code_category, + code_value: code.code_value, + code_name: code.code_name, + code_name_eng: code.code_name_eng, + description: code.description, + sort_order: code.sort_order, + is_active: code.is_active, + company_code: targetCompanyCode, // 새 회사 코드 + menu_objid: newMenuObjid, // 재매핑 + created_by: userId, + }, client); + } + } +} +``` + +--- + +## 구현 단계 + +### Phase 1: 백엔드 서비스 구현 + +**파일**: `backend-node/src/services/menuCopyService.ts` + +#### 1.1 데이터 수집 함수 +- `collectMenuTree()` - 메뉴 트리 수집 +- `collectScreens()` - 화면 수집 (중복 제거) +- `collectFlows()` - 플로우 수집 +- `collectCodes()` - 코드 수집 +- `extractReferencedScreens()` - 화면 참조 추출 + +#### 1.2 복사 함수 +- `copyFlows()` - 플로우 복사 +- `copyScreens()` - 화면 복사 +- `copyMenus()` - 메뉴 복사 +- `copyCodes()` - 코드 복사 +- `createScreenMenuAssignments()` - 화면-메뉴 할당 + +#### 1.3 유틸리티 함수 +- `updateReferencesInProperties()` - JSONB 내부 참조 업데이트 +- `topologicalSortMenus()` - 메뉴 위상 정렬 +- `generateUniqueScreenCode()` - 고유 화면 코드 생성 +- `getNextMenuObjid()` - 다음 메뉴 objid + +### Phase 2: 백엔드 컨트롤러 구현 + +**파일**: `backend-node/src/controllers/menuController.ts` + +```typescript +// POST /api/admin/menus/:menuObjid/copy +export async function copyMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const { targetCompanyCode } = req.body; + const userId = req.user!.userId; + + // 권한 체크 + if (req.user!.companyCode !== "*") { + // 최고 관리자만 가능 + res.status(403).json({ + success: false, + message: "메뉴 복사는 최고 관리자만 가능합니다", + }); + return; + } + + // 복사 실행 + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + parseInt(menuObjid), + targetCompanyCode, + userId + ); + + res.json({ + success: true, + message: "메뉴 복사 완료", + data: result, + }); + + } catch (error: any) { + logger.error("메뉴 복사 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 복사 중 오류가 발생했습니다", + error: error.message, + }); + } +} +``` + +### Phase 3: 백엔드 라우터 등록 + +**파일**: `backend-node/src/routes/admin.ts` + +```typescript +// 메뉴 복사 API +router.post( + "/menus/:menuObjid/copy", + authenticate, + copyMenu +); +``` + +### Phase 4: 프론트엔드 API 클라이언트 + +**파일**: `frontend/lib/api/menu.ts` + +```typescript +/** + * 메뉴 복사 + */ +export async function copyMenu( + menuObjid: number, + targetCompanyCode: string +): Promise> { + try { + const response = await apiClient.post( + `/admin/menus/${menuObjid}/copy`, + { targetCompanyCode } + ); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} + +export interface MenuCopyResult { + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + warnings?: string[]; +} +``` + +### Phase 5: 프론트엔드 UI 구현 + +**파일**: `frontend/components/admin/MenuCopyDialog.tsx` + +```typescript +export function MenuCopyDialog({ + menuObjid, + menuName, + open, + onOpenChange, +}: MenuCopyDialogProps) { + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + const [companies, setCompanies] = useState([]); + const [copying, setCopying] = useState(false); + const [result, setResult] = useState(null); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + loadCompanies(); + } + }, [open]); + + const handleCopy = async () => { + if (!targetCompanyCode) { + toast.error("대상 회사를 선택해주세요"); + return; + } + + setCopying(true); + setResult(null); + + const response = await copyMenu(menuObjid, targetCompanyCode); + + if (response.success && response.data) { + setResult(response.data); + toast.success("메뉴 복사 완료!"); + } else { + toast.error(response.error || "메뉴 복사 실패"); + } + + setCopying(false); + }; + + return ( + + + + + 메뉴 복사 + + + "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다. + + + +
+ {/* 회사 선택 */} +
+ + +
+ + {/* 복사 항목 안내 */} +
+

복사되는 항목:

+
    +
  • 메뉴 구조 (하위 메뉴 포함)
  • +
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • +
  • 플로우 제어 (스텝, 연결)
  • +
  • 코드 카테고리 + 코드
  • +
+
+ + {/* 복사 결과 */} + {result && ( +
+

복사 완료!

+
    +
  • 메뉴: {result.copiedMenus}개
  • +
  • 화면: {result.copiedScreens}개
  • +
  • 플로우: {result.copiedFlows}개
  • +
  • 코드 카테고리: {result.copiedCategories}개
  • +
  • 코드: {result.copiedCodes}개
  • +
+
+ )} +
+ + + + {!result && ( + + )} + +
+
+ ); +} +``` + +### Phase 6: 메뉴 관리 화면 통합 + +**파일**: `frontend/components/admin/MenuManagement.tsx` + +```typescript +// 복사 버튼 추가 + + +// 다이얼로그 + +``` + +--- + +## API 명세 + +### POST /api/admin/menus/:menuObjid/copy + +**설명**: 메뉴와 관련된 모든 리소스를 다른 회사로 복사 + +**권한**: 최고 관리자 전용 (company_code = "*") + +**요청**: +```typescript +POST /api/admin/menus/100/copy +Content-Type: application/json + +{ + "targetCompanyCode": "COMPANY_B" +} +``` + +**응답 (성공)**: +```typescript +{ + "success": true, + "message": "메뉴 복사 완료", + "data": { + "copiedMenus": 5, + "copiedScreens": 12, + "copiedFlows": 3, + "copiedCategories": 8, + "copiedCodes": 45, + "menuIdMap": { + "100": 200, + "101": 201, + "102": 202 + }, + "screenIdMap": { + "10": 30, + "11": 31, + "12": 32 + }, + "flowIdMap": { + "5": 10, + "6": 11 + }, + "warnings": [ + "item_info 테이블에 데이터를 추가해야 합니다", + "메뉴 권한 설정이 필요합니다" + ] + } +} +``` + +**응답 (실패)**: +```typescript +{ + "success": false, + "message": "메뉴 복사 중 오류가 발생했습니다", + "error": "대상 회사가 존재하지 않습니다" +} +``` + +**에러 코드**: +- `403`: 권한 없음 (최고 관리자 아님) +- `404`: 메뉴를 찾을 수 없음 +- `400`: 잘못된 요청 (대상 회사 코드 누락) +- `500`: 서버 내부 오류 + +--- + +## UI/UX 설계 + +### 1. 메뉴 관리 화면 + +``` +┌─────────────────────────────────────────────┐ +│ 메뉴 관리 │ +├─────────────────────────────────────────────┤ +│ ┌─ 영업관리 (objid: 100) │ +│ │ ├─ [편집] [삭제] [복사] ← 복사 버튼 │ +│ │ ├─ 수주관리 (objid: 101) │ +│ │ │ └─ [편집] [삭제] [복사] │ +│ │ └─ 견적관리 (objid: 102) │ +│ │ └─ [편집] [삭제] [복사] │ +│ └─ ... │ +└─────────────────────────────────────────────┘ +``` + +### 2. 복사 다이얼로그 + +#### 초기 상태 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사 * │ +│ [회사 선택 ▼] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 복사되는 항목: │ │ +│ │ • 메뉴 구조 (하위 메뉴 포함) │ │ +│ │ • 화면 + 레이아웃 │ │ +│ │ • 플로우 제어 │ │ +│ │ • 코드 카테고리 + 코드 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [취소] [복사 시작] │ +└─────────────────────────────────────────┘ +``` + +#### 복사 중 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사: 회사B (COMPANY_B) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ⚙️ 복사 진행 중... │ │ +│ │ │ │ +│ │ ✅ 메뉴 수집 완료 │ │ +│ │ ✅ 화면 수집 완료 │ │ +│ │ ⏳ 플로우 복사 중... │ │ +│ │ ⬜ 화면 복사 대기 │ │ +│ │ ⬜ 메뉴 복사 대기 │ │ +│ │ ⬜ 코드 복사 대기 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [취소 불가] │ +└─────────────────────────────────────────┘ +``` + +#### 복사 완료 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사: 회사B (COMPANY_B) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ✅ 복사 완료! │ │ +│ │ │ │ +│ │ • 메뉴: 5개 │ │ +│ │ • 화면: 12개 │ │ +│ │ • 플로우: 3개 │ │ +│ │ • 코드 카테고리: 8개 │ │ +│ │ • 코드: 45개 │ │ +│ │ │ │ +│ │ ⚠️ 주의사항: │ │ +│ │ - 실제 데이터는 복사되지 않음 │ │ +│ │ - 메뉴 권한 설정 필요 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [닫기] │ +└─────────────────────────────────────────┘ +``` + +### 3. 사용자 흐름 + +``` +1. 메뉴 관리 화면 접속 + ↓ +2. 복사할 메뉴 옆 [복사] 버튼 클릭 + ↓ +3. 복사 다이얼로그 열림 + ↓ +4. 대상 회사 선택 + ↓ +5. [복사 시작] 버튼 클릭 + ↓ +6. 진행 상황 표시 (30초 ~ 2분) + ↓ +7. 완료 메시지 확인 + ↓ +8. [닫기] 버튼으로 다이얼로그 닫기 +``` + +--- + +## 예외 처리 + +### 1. 권한 검증 +```typescript +if (req.user!.companyCode !== "*") { + throw new Error("메뉴 복사는 최고 관리자만 가능합니다"); +} +``` + +### 2. 메뉴 존재 여부 +```typescript +const menu = await getMenuByObjid(menuObjid, client); +if (!menu) { + throw new Error("메뉴를 찾을 수 없습니다"); +} +``` + +### 3. 대상 회사 존재 여부 +```typescript +const company = await getCompanyByCode(targetCompanyCode, client); +if (!company) { + throw new Error("대상 회사가 존재하지 않습니다"); +} +``` + +### 4. 중복 메뉴 체크 +```typescript +// 같은 이름의 메뉴가 이미 있는지 확인 +const existingMenu = await getMenuByNameAndCompany( + menu.menu_name_kor, + targetCompanyCode, + client +); + +if (existingMenu) { + // 경고만 표시하고 진행 (사용자가 이름 변경 가능) + warnings.push(`같은 이름의 메뉴가 이미 존재합니다: ${menu.menu_name_kor}`); +} +``` + +### 5. 트랜잭션 롤백 +```typescript +try { + await client.query('BEGIN'); + // ... 복사 작업 + await client.query('COMMIT'); +} catch (error) { + await client.query('ROLLBACK'); + logger.error("메뉴 복사 실패, 롤백됨:", error); + throw error; +} +``` + +### 6. 무한 루프 방지 +```typescript +// 화면 참조 추적 시 +const visited = new Set(); + +function collectScreensRecursive(screenId: number) { + if (visited.has(screenId)) return; // 이미 방문함 + visited.add(screenId); + // ... 참조 화면 수집 +} +``` + +### 7. JSONB 파싱 오류 +```typescript +try { + const properties = JSON.parse(layout.properties); + // ... properties 처리 +} catch (error) { + logger.warn(`JSONB 파싱 실패: layout_id=${layout.layout_id}`, error); + // 원본 그대로 사용 +} +``` + +### 8. 부분 실패 처리 +```typescript +// 플로우 복사 실패 시 경고만 표시하고 계속 진행 +try { + await copyFlows(flowIds, targetCompanyCode, userId, client); +} catch (error) { + logger.error("플로우 복사 실패:", error); + warnings.push("일부 플로우가 복사되지 않았습니다"); + // 계속 진행 (메뉴와 화면은 복사) +} +``` + +--- + +## 테스트 계획 + +### 단위 테스트 (Unit Tests) + +#### 1. 수집 함수 테스트 +```typescript +describe("MenuCopyService - Collection", () => { + test("collectMenuTree: 하위 메뉴 재귀 수집", async () => { + const menus = await collectMenuTree(100); + expect(menus.length).toBeGreaterThan(1); + expect(menus[0].objid).toBe(100); + }); + + test("collectScreens: 중복 제거", async () => { + const screenIds = await collectScreens([100, 101]); + const uniqueIds = new Set(screenIds); + expect(screenIds.length).toBe(uniqueIds.size); + }); + + test("extractReferencedScreens: 모달 참조 추출", async () => { + const referenced = extractReferencedScreens(10); + expect(referenced).toContain(26); // 모달 화면 ID + }); +}); +``` + +#### 2. 복사 함수 테스트 +```typescript +describe("MenuCopyService - Copy", () => { + test("copyFlows: 플로우 + 스텝 + 연결 복사", async () => { + const flowIdMap = await copyFlows( + new Set([5]), + "TEST_COMPANY", + "test_user", + client + ); + + expect(flowIdMap.size).toBe(1); + const newFlowId = flowIdMap.get(5); + expect(newFlowId).toBeDefined(); + + const steps = await getFlowSteps(newFlowId!, client); + expect(steps.length).toBeGreaterThan(0); + }); + + test("copyScreens: properties 내부 참조 업데이트", async () => { + const screenIdMap = await copyScreens( + new Set([10]), + "TEST_COMPANY", + new Map(), // flowIdMap + "test_user", + client + ); + + const newScreenId = screenIdMap.get(10); + const layouts = await getScreenLayouts(newScreenId!, client); + + // targetScreenId가 재매핑되었는지 확인 + const modalLayout = layouts.find( + l => l.properties?.componentConfig?.action?.type === "modal" + ); + expect(modalLayout?.properties.componentConfig.action.targetScreenId).not.toBe(26); + }); +}); +``` + +#### 3. ID 재매핑 테스트 +```typescript +describe("MenuCopyService - Remapping", () => { + test("updateReferencesInProperties: 모달 참조 업데이트", () => { + const properties = { + componentConfig: { + action: { + type: "modal", + targetScreenId: 26 + } + } + }; + + const screenIdMap = new Map([[26, 50]]); + const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); + + expect(updated.componentConfig.action.targetScreenId).toBe(50); + }); + + test("updateReferencesInProperties: 조건부 컨테이너 참조 업데이트", () => { + const properties = { + sections: [ + { id: "1", condition: "A", screenId: 10 }, + { id: "2", condition: "B", screenId: 11 } + ] + }; + + const screenIdMap = new Map([[10, 30], [11, 31]]); + const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); + + expect(updated.sections[0].screenId).toBe(30); + expect(updated.sections[1].screenId).toBe(31); + }); +}); +``` + +### 통합 테스트 (Integration Tests) + +#### 1. 전체 복사 플로우 +```typescript +describe("Menu Copy - Full Flow", () => { + let testMenuObjid: number; + let targetCompanyCode: string; + + beforeAll(async () => { + // 테스트 데이터 준비 + testMenuObjid = await createTestMenu(); + targetCompanyCode = "TEST_COMPANY_" + Date.now(); + await createTestCompany(targetCompanyCode); + }); + + afterAll(async () => { + // 테스트 데이터 정리 + await deleteTestData(targetCompanyCode); + }); + + test("메뉴 복사: 성공", async () => { + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + testMenuObjid, + targetCompanyCode, + "test_user" + ); + + expect(result.success).toBe(true); + expect(result.copiedMenus).toBeGreaterThan(0); + expect(result.copiedScreens).toBeGreaterThan(0); + + // 복사된 메뉴 검증 + const copiedMenus = await getMenusByCompany(targetCompanyCode); + expect(copiedMenus.length).toBe(result.copiedMenus); + + // 복사된 화면 검증 + const copiedScreens = await getScreensByCompany(targetCompanyCode); + expect(copiedScreens.length).toBe(result.copiedScreens); + }); + + test("복사된 화면이 정상 작동", async () => { + // 복사된 화면에서 데이터 조회 가능한지 확인 + const screens = await getScreensByCompany(targetCompanyCode); + const firstScreen = screens[0]; + + const layouts = await getScreenLayouts(firstScreen.screen_id); + expect(layouts.length).toBeGreaterThan(0); + }); +}); +``` + +#### 2. 트랜잭션 롤백 테스트 +```typescript +describe("Menu Copy - Rollback", () => { + test("실패 시 롤백", async () => { + const invalidCompanyCode = "INVALID_COMPANY"; + + const menuCopyService = new MenuCopyService(); + + await expect( + menuCopyService.copyMenu(100, invalidCompanyCode, "test_user") + ).rejects.toThrow(); + + // 롤백 확인: 데이터가 생성되지 않았는지 + const menus = await getMenusByCompany(invalidCompanyCode); + expect(menus.length).toBe(0); + }); +}); +``` + +### E2E 테스트 (End-to-End Tests) + +#### 1. UI 테스트 +```typescript +describe("Menu Copy - E2E", () => { + test("메뉴 관리 화면에서 복사 버튼 클릭", async () => { + // 1. 로그인 + await page.goto("http://localhost:9771/login"); + await page.fill('input[name="userId"]', "wace"); + await page.fill('input[name="password"]', "qlalfqjsgh11"); + await page.click('button[type="submit"]'); + + // 2. 메뉴 관리 화면 이동 + await page.goto("http://localhost:9771/admin/menus"); + await page.waitForSelector(".menu-list"); + + // 3. 복사 버튼 클릭 + await page.click('button[aria-label="메뉴 복사"]'); + + // 4. 대상 회사 선택 + await page.selectOption('select[name="targetCompany"]', "COMPANY_B"); + + // 5. 복사 시작 + await page.click('button:has-text("복사 시작")'); + + // 6. 완료 메시지 확인 + await page.waitForSelector('text=복사 완료', { timeout: 120000 }); + + // 7. 복사된 메뉴 확인 + await page.selectOption('select[name="company"]', "COMPANY_B"); + await page.waitForSelector('.menu-list'); + const menuCount = await page.locator('.menu-item').count(); + expect(menuCount).toBeGreaterThan(0); + }); +}); +``` + +### 성능 테스트 + +#### 1. 대량 메뉴 복사 +```typescript +test("100개 메뉴 복사 성능", async () => { + const startTime = Date.now(); + + const result = await menuCopyService.copyMenu( + largeMenuObjid, // 하위 메뉴 100개 + "TEST_COMPANY", + "test_user" + ); + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(duration).toBeLessThan(120000); // 2분 이내 + expect(result.copiedMenus).toBe(100); +}); +``` + +#### 2. 동시 복사 요청 +```typescript +test("동시 복사 요청 처리", async () => { + const promises = Array.from({ length: 5 }, (_, i) => + menuCopyService.copyMenu( + testMenuObjid, + `TEST_COMPANY_${i}`, + "test_user" + ) + ); + + const results = await Promise.all(promises); + + expect(results.every(r => r.success)).toBe(true); +}); +``` + +--- + +## 구현 체크리스트 + +### 백엔드 +- [ ] `menuCopyService.ts` 생성 + - [ ] `collectMenuTree()` + - [ ] `collectScreens()` + - [ ] `collectFlows()` + - [ ] `collectCodes()` + - [ ] `extractReferencedScreens()` + - [ ] `copyFlows()` + - [ ] `copyScreens()` + - [ ] `copyMenus()` + - [ ] `copyCodes()` + - [ ] `createScreenMenuAssignments()` + - [ ] `updateReferencesInProperties()` + - [ ] `topologicalSortMenus()` + - [ ] `generateUniqueScreenCode()` +- [ ] `menuController.ts` 업데이트 + - [ ] `copyMenu()` 컨트롤러 추가 +- [ ] `admin.ts` 라우터 업데이트 + - [ ] `/menus/:menuObjid/copy` 라우트 추가 +- [ ] 단위 테스트 작성 +- [ ] 통합 테스트 작성 + +### 프론트엔드 +- [ ] `menu.ts` API 클라이언트 업데이트 + - [ ] `copyMenu()` 함수 추가 + - [ ] `MenuCopyResult` 인터페이스 추가 +- [ ] `MenuCopyDialog.tsx` 생성 + - [ ] 회사 선택 드롭다운 + - [ ] 복사 진행 상태 표시 + - [ ] 복사 결과 표시 +- [ ] `MenuManagement.tsx` 업데이트 + - [ ] 복사 버튼 추가 + - [ ] 다이얼로그 통합 +- [ ] E2E 테스트 작성 + +### 문서 +- [ ] API 문서 업데이트 +- [ ] 사용자 매뉴얼 작성 +- [ ] 개발자 가이드 작성 + +--- + +## 예상 소요 시간 + +| 단계 | 작업 | 예상 시간 | +|------|------|-----------| +| 1 | 백엔드 서비스 구현 | 6시간 | +| 2 | 백엔드 컨트롤러/라우터 | 1시간 | +| 3 | 백엔드 테스트 | 3시간 | +| 4 | 프론트엔드 API 클라이언트 | 0.5시간 | +| 5 | 프론트엔드 UI 구현 | 3시간 | +| 6 | 프론트엔드 통합 | 1시간 | +| 7 | E2E 테스트 | 2시간 | +| 8 | 문서 작성 | 1.5시간 | +| 9 | 버그 수정 및 최적화 | 2시간 | +| **총계** | | **20시간** | + +--- + +## 참고 사항 + +### 멀티테넌시 주의사항 +- 모든 쿼리에 `company_code` 필터링 적용 +- 최고 관리자(company_code = "*")만 메뉴 복사 가능 +- 복사 시 `company_code`를 대상 회사 코드로 변경 + +### 데이터 무결성 +- 외래키 제약조건 순서 준수 +- 트랜잭션으로 원자성 보장 +- 중복 데이터 체크 및 병합 + +### 성능 최적화 +- 배치 삽입 사용 (bulk insert) +- 불필요한 쿼리 최소화 +- ID 매핑 테이블로 참조 업데이트 + +### 보안 +- 권한 검증 (최고 관리자만) +- SQL 인젝션 방지 +- 입력값 검증 + +--- + +## 변경 이력 + +| 날짜 | 버전 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 2025-01-24 | 1.0 | AI | 초안 작성 | + + diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx new file mode 100644 index 00000000..46de8f4b --- /dev/null +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { menuApi, MenuCopyResult } from "@/lib/api/menu"; +import { apiClient } from "@/lib/api/client"; + +interface MenuCopyDialogProps { + menuObjid: number | null; + menuName: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onCopyComplete?: () => void; +} + +interface Company { + company_code: string; + company_name: string; +} + +export function MenuCopyDialog({ + menuObjid, + menuName, + open, + onOpenChange, + onCopyComplete, +}: MenuCopyDialogProps) { + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + const [companies, setCompanies] = useState([]); + const [copying, setCopying] = useState(false); + const [result, setResult] = useState(null); + const [loadingCompanies, setLoadingCompanies] = useState(false); + + // 화면명 일괄 변경 설정 + const [useBulkRename, setUseBulkRename] = useState(false); + const [removeText, setRemoveText] = useState(""); + const [addPrefix, setAddPrefix] = useState(""); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + loadCompanies(); + // 다이얼로그가 열릴 때마다 초기화 + setTargetCompanyCode(""); + setResult(null); + setUseBulkRename(false); + setRemoveText(""); + setAddPrefix(""); + } + }, [open]); + + const loadCompanies = async () => { + try { + setLoadingCompanies(true); + const response = await apiClient.get("/admin/companies/db"); + if (response.data.success && response.data.data) { + // 최고 관리자(*) 회사 제외 + const filteredCompanies = response.data.data.filter( + (company: Company) => company.company_code !== "*" + ); + setCompanies(filteredCompanies); + } + } catch (error) { + console.error("회사 목록 조회 실패:", error); + toast.error("회사 목록을 불러올 수 없습니다"); + } finally { + setLoadingCompanies(false); + } + }; + + const handleCopy = async () => { + if (!menuObjid) { + toast.error("메뉴를 선택해주세요"); + return; + } + + if (!targetCompanyCode) { + toast.error("대상 회사를 선택해주세요"); + return; + } + + setCopying(true); + setResult(null); + + try { + // 화면명 변환 설정 (사용 중일 때만 전달) + const screenNameConfig = + useBulkRename && (removeText.trim() || addPrefix.trim()) + ? { + removeText: removeText.trim() || undefined, + addPrefix: addPrefix.trim() || undefined, + } + : undefined; + + const response = await menuApi.copyMenu( + menuObjid, + targetCompanyCode, + screenNameConfig + ); + + if (response.success && response.data) { + setResult(response.data); + toast.success("메뉴 복사 완료!"); + + // 경고 메시지 표시 + if (response.data.warnings && response.data.warnings.length > 0) { + response.data.warnings.forEach((warning) => { + toast.warning(warning); + }); + } + + // 복사 완료 콜백 + if (onCopyComplete) { + onCopyComplete(); + } + } else { + toast.error(response.message || "메뉴 복사 실패"); + } + } catch (error: any) { + console.error("메뉴 복사 오류:", error); + toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다"); + } finally { + setCopying(false); + } + }; + + const handleClose = () => { + if (!copying) { + onOpenChange(false); + } + }; + + return ( + + + + + 메뉴 복사 + + + "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다. + + + +
+ {/* 회사 선택 */} + {!result && ( +
+ + +
+ )} + + {/* 화면명 일괄 변경 설정 */} + {!result && ( +
+
+ setUseBulkRename(checked as boolean)} + disabled={copying} + /> + +
+ + {useBulkRename && ( +
+
+ + setRemoveText(e.target.value)} + placeholder="예: 탑씰" + disabled={copying} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 화면명에서 이 텍스트를 제거합니다 (예: "탑씰 회사정보" → "회사정보") +

+
+ +
+ + setAddPrefix(e.target.value)} + placeholder="예: 한신" + disabled={copying} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 화면명 앞에 이 텍스트를 추가합니다 (예: "회사정보" → "한신 회사정보") +

+
+
+ )} +
+ )} + + {/* 복사 항목 안내 */} + {!result && ( +
+

복사되는 항목:

+
    +
  • 메뉴 구조 (하위 메뉴 포함)
  • +
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • +
  • 플로우 제어 (스텝, 연결)
  • +
  • 코드 카테고리 + 코드
  • +
  • 카테고리 설정 + 채번 규칙
  • +
+

+ ⚠️ 실제 데이터는 복사되지 않습니다. +

+
+ )} + + {/* 복사 결과 */} + {result && ( +
+

✅ 복사 완료!

+
+
+ 메뉴:{" "} + {result.copiedMenus}개 +
+
+ 화면:{" "} + {result.copiedScreens}개 +
+
+ 플로우:{" "} + {result.copiedFlows}개 +
+
+ 코드 카테고리:{" "} + {result.copiedCategories}개 +
+
+ 코드:{" "} + {result.copiedCodes}개 +
+
+
+ )} +
+ + + + {!result && ( + + )} + +
+
+ ); +} + diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx index 6671504e..67e8bab6 100644 --- a/frontend/components/admin/MenuManagement.tsx +++ b/frontend/components/admin/MenuManagement.tsx @@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu"; import type { MenuItem } from "@/lib/api/menu"; import { MenuTable } from "./MenuTable"; import { MenuFormModal } from "./MenuFormModal"; +import { MenuCopyDialog } from "./MenuCopyDialog"; import { Button } from "@/components/ui/button"; import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; @@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext"; import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang"; import { useMultiLang } from "@/hooks/useMultiLang"; import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; // useAuth 추가 type MenuType = "admin" | "user"; export const MenuManagement: React.FC = () => { const { adminMenus, userMenus, refreshMenus } = useMenu(); + const { user } = useAuth(); // 현재 사용자 정보 가져오기 const [selectedMenuType, setSelectedMenuType] = useState("admin"); const [loading, setLoading] = useState(false); const [deleting, setDeleting] = useState(false); const [formModalOpen, setFormModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [selectedMenuId, setSelectedMenuId] = useState(""); + const [selectedMenuName, setSelectedMenuName] = useState(""); const [selectedMenus, setSelectedMenus] = useState>(new Set()); // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시) @@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => { // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용 const { userLang } = useMultiLang({ companyCode: "*" }); + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = user?.userType === "SUPER_ADMIN"; + // 다국어 텍스트 상태 const [uiTexts, setUiTexts] = useState>({}); const [uiTextsLoading, setUiTextsLoading] = useState(false); @@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => { } }; + const handleCopyMenu = (menuId: string, menuName: string) => { + setSelectedMenuId(menuId); + setSelectedMenuName(menuName); + setCopyDialogOpen(true); + }; + + const handleCopyComplete = async () => { + // 복사 완료 후 메뉴 목록 새로고침 + await loadMenus(false); + toast.success("메뉴 복사가 완료되었습니다"); + }; + const handleToggleStatus = async (menuId: string) => { try { const response = await menuApi.toggleMenuStatus(menuId); @@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => { title="" onAddMenu={handleAddMenu} onEditMenu={handleEditMenu} + onCopyMenu={handleCopyMenu} onToggleStatus={handleToggleStatus} selectedMenus={selectedMenus} onMenuSelectionChange={handleMenuSelectionChange} @@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => { expandedMenus={expandedMenus} onToggleExpand={handleToggleExpand} uiTexts={uiTexts} + isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달 /> @@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => { + + ); }; diff --git a/frontend/components/admin/MenuTable.tsx b/frontend/components/admin/MenuTable.tsx index 9ca243bc..644c84f3 100644 --- a/frontend/components/admin/MenuTable.tsx +++ b/frontend/components/admin/MenuTable.tsx @@ -14,6 +14,7 @@ interface MenuTableProps { title: string; onAddMenu: (parentId: string, menuType: string, level: number) => void; onEditMenu: (menuId: string) => void; + onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가 onToggleStatus: (menuId: string) => void; selectedMenus: Set; onMenuSelectionChange: (menuId: string, checked: boolean) => void; @@ -22,6 +23,7 @@ interface MenuTableProps { onToggleExpand: (menuId: string) => void; // 다국어 텍스트 props 추가 uiTexts: Record; + isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가 } export const MenuTable: React.FC = ({ @@ -29,6 +31,7 @@ export const MenuTable: React.FC = ({ title, onAddMenu, onEditMenu, + onCopyMenu, onToggleStatus, selectedMenus, onMenuSelectionChange, @@ -36,6 +39,7 @@ export const MenuTable: React.FC = ({ expandedMenus, onToggleExpand, uiTexts, + isSuperAdmin = false, // 기본값 false }) => { // 다국어 텍스트 가져오기 함수 const getText = (key: string, fallback?: string): string => { @@ -281,14 +285,26 @@ export const MenuTable: React.FC = ({
{lev === 1 && ( - + <> + + {isSuperAdmin && ( + + )} + )} {lev === 2 && ( <> @@ -308,17 +324,39 @@ export const MenuTable: React.FC = ({ > {getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)} + {isSuperAdmin && ( + + )} )} {lev > 2 && ( - + <> + + {isSuperAdmin && ( + + )} + )}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 4aa3db0f..a81e75dc 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -74,6 +74,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const [draggedTool, setDraggedTool] = useState(null); const [draggedAreaData, setDraggedAreaData] = useState(null); // 드래그 중인 Area 정보 const [draggedLocationData, setDraggedLocationData] = useState(null); // 드래그 중인 Location 정보 + const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); @@ -779,9 +780,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return; } - // 부모 ID 설정 + // 부모 ID 설정 및 논리적 유효성 검사 if (validation.parent) { + // 1. 부모 객체 찾기 + const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id); + + // 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우) + if (parentObj && parentObj.externalKey && newObject.parentKey) { + if (parentObj.externalKey !== newObject.parentKey) { + toast({ + variant: "destructive", + title: "배치 오류", + description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`, + }); + return; + } + } + newObject.parentId = validation.parent.id; + } else if (newObject.parentKey) { + // DB 데이터인데 부모 영역 위에 놓이지 않은 경우 + toast({ + variant: "destructive", + title: "배치 오류", + description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`, + }); + return; } } @@ -810,7 +834,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string) => { + console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material }); + if (!selectedDbConnection || !hierarchyConfig?.material) { + console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material }); toast({ variant: "destructive", title: "자재 조회 실패", @@ -822,10 +849,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { setLoadingMaterials(true); setShowMaterialPanel(true); - const response = await getMaterials(selectedDbConnection, { + + const materialConfig = { ...hierarchyConfig.material, locaKey: locaKey, - }); + }; + console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig }); + + const response = await getMaterials(selectedDbConnection, materialConfig); + console.log("📦 API 응답:", response); if (response.success && response.data) { // layerColumn이 있으면 정렬 const sortedMaterials = hierarchyConfig.material.layerColumn @@ -965,7 +997,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return obj; }); - // 2. 그룹 이동: 자식 객체들도 함께 이동 + // 2. 하위 계층 객체 이동 시 논리적 키 검증 + if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) { + const spatialObjects = updatedObjects.map((obj) => ({ + id: obj.id, + position: obj.position, + size: obj.size, + hierarchyLevel: obj.hierarchyLevel || 1, + parentId: obj.parentId, + })); + + const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId); + if (targetSpatialObj) { + const validation = validateSpatialContainment( + targetSpatialObj, + spatialObjects.filter((obj) => obj.id !== objectId), + ); + + // 새로운 부모 영역 찾기 + if (validation.parent) { + const newParentObj = prev.find((obj) => obj.id === validation.parent!.id); + + // DB에서 가져온 데이터인 경우 논리적 키 검증 + if (newParentObj && newParentObj.externalKey && targetObj.parentKey) { + if (newParentObj.externalKey !== targetObj.parentKey) { + toast({ + variant: "destructive", + title: "이동 불가", + description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`, + }); + return prev; // 이동 취소 + } + } + + // 부모 ID 업데이트 + updatedObjects = updatedObjects.map((obj) => { + if (obj.id === objectId) { + return { ...obj, parentId: validation.parent!.id }; + } + return obj; + }); + } else if (targetObj.parentKey) { + // DB 데이터인데 부모 영역 밖으로 이동하려는 경우 + toast({ + variant: "destructive", + title: "이동 불가", + description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`, + }); + return prev; // 이동 취소 + } + } + } + + // 3. 그룹 이동: 자식 객체들도 함께 이동 const spatialObjects = updatedObjects.map((obj) => ({ id: obj.id, position: obj.position, @@ -1493,77 +1577,185 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi )} - {/* 배치된 객체 목록 */} -
+ {/* 배치된 객체 목록 (계층 구조) */} +

배치된 객체 ({placedObjects.length})

{placedObjects.length === 0 ? (
상단 도구를 드래그하여 배치하세요
) : ( -
- {placedObjects.map((obj) => ( -
handleObjectClick(obj.id)} - className={`cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50" - }`} - > -
- {obj.name} -
-
-

- 위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) -

- {obj.areaKey &&

Area: {obj.areaKey}

} -
- ))} -
+ + {/* Area별로 그룹핑 */} + {(() => { + // Area 객체들 + const areaObjects = placedObjects.filter((obj) => obj.type === "area"); + + // Area가 없으면 기존 방식으로 표시 + if (areaObjects.length === 0) { + return ( +
+ {placedObjects.map((obj) => ( +
handleObjectClick(obj.id)} + className={`cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50" + }`} + > +
+ {obj.name} +
+
+

+ 위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) +

+
+ ))} +
+ ); + } + + // Area별로 Location들을 그룹핑 + return areaObjects.map((areaObj) => { + // 이 Area의 자식 Location들 찾기 + const childLocations = placedObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); + + return ( + + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) +
+
+
+ + + {childLocations.length === 0 ? ( +

Location이 없습니다

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ + {locationObj.name} +
+
+
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Key: {locationObj.locaKey} +

+ )} +
+ ))} +
+ )} + + + ); + }); + })()} + )}
{/* 중앙: 3D 캔버스 */} -
e.preventDefault()} - onDrop={(e) => { - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; - const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; - - // 그리드 크기 (5 단위) - const gridSize = 5; - - // 그리드에 스냅 - // Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 - let snappedX = Math.round(rawX / gridSize) * gridSize; - let snappedZ = Math.round(rawZ / gridSize) * gridSize; - - // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) - if (draggedTool !== "area") { - snappedX += gridSize / 2; - snappedZ += gridSize / 2; - } - - handleCanvasDrop(snappedX, snappedZ); - }} - > +
{isLoading ? (
) : ( - handleObjectClick(placement?.id || null)} - onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> + <> + handleObjectClick(placement?.id || null)} + onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + previewTool={draggedTool} + previewPosition={previewPosition} + onPreviewPositionUpdate={setPreviewPosition} + /> + {/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */} + {draggedTool && ( +
{ + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; + const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; + + // 그리드 크기 (5 단위) + const gridSize = 5; + + // 그리드에 스냅 + let snappedX = Math.round(rawX / gridSize) * gridSize; + let snappedZ = Math.round(rawZ / gridSize) * gridSize; + + // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) + if (draggedTool !== "area") { + snappedX += gridSize / 2; + snappedZ += gridSize / 2; + } + + setPreviewPosition({ x: snappedX, z: snappedZ }); + }} + onDragLeave={() => { + setPreviewPosition(null); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (previewPosition) { + handleCanvasDrop(previewPosition.x, previewPosition.z); + setPreviewPosition(null); + } + setDraggedTool(null); + setDraggedAreaData(null); + setDraggedLocationData(null); + }} + /> + )} + )}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 31cca6f8..770a2bad 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -78,37 +78,52 @@ export default function HierarchyConfigPanel({ const [loadingColumns, setLoadingColumns] = useState(false); const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); + // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 useEffect(() => { if (hierarchyConfig) { setLocalConfig(hierarchyConfig); - // 저장된 설정이 있으면 해당 테이블들의 컬럼을 자동 로드 + // 저장된 설정의 테이블들에 대한 컬럼 자동 로드 const loadSavedColumns = async () => { - // 창고 테이블 컬럼 로드 + const tablesToLoad: string[] = []; + + // 창고 테이블 if (hierarchyConfig.warehouse?.tableName) { - await handleTableChange(hierarchyConfig.warehouse.tableName, "warehouse"); + tablesToLoad.push(hierarchyConfig.warehouse.tableName); } - // 레벨 테이블 컬럼 로드 - if (hierarchyConfig.levels) { - for (const level of hierarchyConfig.levels) { - if (level.tableName) { - await handleTableChange(level.tableName, level.level); + // 계층 레벨 테이블들 + hierarchyConfig.levels?.forEach((level) => { + if (level.tableName) { + tablesToLoad.push(level.tableName); + } + }); + + // 자재 테이블 + if (hierarchyConfig.material?.tableName) { + tablesToLoad.push(hierarchyConfig.material.tableName); + } + + // 중복 제거 후 로드 + const uniqueTables = [...new Set(tablesToLoad)]; + for (const tableName of uniqueTables) { + if (!columnsCache[tableName]) { + try { + const columns = await onLoadColumns(tableName); + setColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + } catch (error) { + console.error(`컬럼 로드 실패 (${tableName}):`, error); } } } - - // 자재 테이블 컬럼 로드 - if (hierarchyConfig.material?.tableName) { - await handleTableChange(hierarchyConfig.material.tableName, "material"); - } }; - loadSavedColumns(); + if (externalDbConnectionId) { + loadSavedColumns(); + } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hierarchyConfig]); + }, [hierarchyConfig, externalDbConnectionId]); // 테이블 선택 시 컬럼 로드 const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { @@ -229,7 +244,7 @@ export default function HierarchyConfigPanel({
{table.table_name} {table.description && ( - {table.description} + {table.description} )}
@@ -237,7 +252,7 @@ export default function HierarchyConfigPanel({ {!localConfig.warehouse?.tableName && ( -

+

ℹ️ 창고 테이블을 선택하고 "설정 적용"을 눌러주세요

)} @@ -261,7 +276,7 @@ export default function HierarchyConfigPanel({
{col.column_name} {col.description && ( - {col.description} + {col.description} )}
@@ -285,7 +300,7 @@ export default function HierarchyConfigPanel({
{col.column_name} {col.description && ( - {col.description} + {col.description} )}
@@ -349,7 +364,7 @@ export default function HierarchyConfigPanel({
{table.table_name} {table.description && ( - {table.description} + {table.description} )}
@@ -360,52 +375,52 @@ export default function HierarchyConfigPanel({ {level.tableName && columnsCache[level.tableName] && ( <> -
- - -
+
+ + +
-
- - +
+ +
@@ -423,7 +438,7 @@ export default function HierarchyConfigPanel({
{col.column_name} {col.description && ( - {col.description} + {col.description} )}
@@ -450,7 +465,7 @@ export default function HierarchyConfigPanel({
{col.column_name} {col.description && ( - {col.description} + {col.description} )}
@@ -496,7 +511,7 @@ export default function HierarchyConfigPanel({
{table.table_name} {table.description && ( - {table.description} + {table.description} )}
@@ -507,102 +522,102 @@ export default function HierarchyConfigPanel({ {localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && ( <> -
- - -
- -
- - +
+ +
-
- - handleMaterialChange("locationKeyColumn", val)} + > + + + + + {columnsCache[localConfig.material.tableName].map((col) => ( + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
+
+ ))} +
+ +
+ +
+ + -
+ {columnsCache[localConfig.material.tableName].map((col) => ( + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
+
+ ))} + + +
-
- - handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)} - > - - - - + > + + + + 없음 - {columnsCache[localConfig.material.tableName].map((col) => ( - -
- {col.column_name} - {col.description && ( - {col.description} - )} -
-
- ))} -
- + {columnsCache[localConfig.material.tableName].map((col) => ( + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
+
+ ))} +
+
@@ -634,7 +649,7 @@ export default function HierarchyConfigPanel({
{col.column_name} {col.description && ( - {col.description} + {col.description} )}
{isSelected && ( diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index f0f406b7..45833022 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -35,6 +35,9 @@ interface Yard3DCanvasProps { gridSize?: number; // 그리드 크기 (기본값: 5) onCollisionDetected?: () => void; // 충돌 감지 시 콜백 focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID + previewTool?: string | null; // 드래그 중인 도구 타입 + previewPosition?: { x: number; z: number } | null; // 프리뷰 위치 + onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void; } // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) @@ -1066,10 +1069,26 @@ function Scene({ gridSize = 5, onCollisionDetected, focusOnPlacementId, + previewTool, + previewPosition, }: Yard3DCanvasProps) { const [isDraggingAny, setIsDraggingAny] = useState(false); const orbitControlsRef = useRef(null); + // 프리뷰 박스 크기 계산 + const getPreviewSize = (tool: string) => { + if (tool === "area") return { x: 20, y: 0.1, z: 20 }; + return { x: 5, y: 5, z: 5 }; + }; + + // 프리뷰 박스 색상 + const getPreviewColor = (tool: string) => { + if (tool === "area") return "#3b82f6"; + if (tool === "location-bed") return "#10b981"; + if (tool === "location-stp") return "#f59e0b"; + return "#9ca3af"; + }; + return ( <> {/* 카메라 포커스 컨트롤러 */} @@ -1128,6 +1147,30 @@ function Scene({ /> ))} + {/* 드래그 프리뷰 박스 */} + {previewTool && previewPosition && ( + + + + )} + {/* 카메라 컨트롤 */} { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 @@ -1182,6 +1228,8 @@ export default function Yard3DCanvas({ gridSize={gridSize} onCollisionDetected={onCollisionDetected} focusOnPlacementId={focusOnPlacementId} + previewTool={previewTool} + previewPosition={previewPosition} /> diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index f44e2227..65dbf84c 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -57,16 +57,18 @@ export const ScreenModal: React.FC = ({ className }) => { // 폼 데이터 상태 추가 const [formData, setFormData] = useState>({}); - // 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록) - const continuousModeRef = useRef(false); - const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음) + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) + const [continuousMode, setContinuousMode] = useState(false); + + // 화면 리셋 키 (컴포넌트 강제 리마운트용) + const [resetKey, setResetKey] = useState(0); // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); if (savedMode === "true") { - continuousModeRef.current = true; - // console.log("🔄 연속 모드 복원: true"); + setContinuousMode(true); + console.log("🔄 연속 모드 복원: true"); } }, []); @@ -162,29 +164,39 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); - continuousModeRef.current = false; + setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 - // console.log("🔄 연속 모드 초기화: false"); + console.log("🔄 연속 모드 초기화: false"); }; // 저장 성공 이벤트 처리 (연속 등록 모드 지원) const handleSaveSuccess = () => { - const isContinuousMode = continuousModeRef.current; - // console.log("💾 저장 성공 이벤트 수신"); - // console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode); - // console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); + const isContinuousMode = continuousMode; + console.log("💾 저장 성공 이벤트 수신"); + console.log("📌 현재 연속 모드 상태:", isContinuousMode); + console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 - // console.log("✅ 연속 모드 활성화 - 폼만 초기화"); + console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋"); - // 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨) + // 1. 폼 데이터 초기화 setFormData({}); + + // 2. 리셋 키 변경 (컴포넌트 강제 리마운트) + setResetKey(prev => prev + 1); + console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); + + // 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성) + if (modalState.screenId) { + console.log("🔄 화면 데이터 다시 로드:", modalState.screenId); + loadScreenData(modalState.screenId); + } toast.success("저장되었습니다. 계속 입력하세요."); } else { // 일반 모드: 모달 닫기 - // console.log("❌ 일반 모드 - 모달 닫기"); + console.log("❌ 일반 모드 - 모달 닫기"); handleCloseModal(); } }; @@ -198,7 +210,7 @@ export const ScreenModal: React.FC = ({ className }) => { window.removeEventListener("closeSaveModal", handleCloseModal); window.removeEventListener("saveSuccessInModal", handleSaveSuccess); }; - }, []); // 의존성 제거 (ref 사용으로 최신 상태 참조) + }, [continuousMode]); // continuousMode 의존성 추가 // 화면 데이터 로딩 useEffect(() => { @@ -415,18 +427,21 @@ export const ScreenModal: React.FC = ({ className }) => { setFormData({}); // 폼 데이터 초기화 }; - // 모달 크기 설정 - 화면 내용에 맞게 동적 조정 + // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { return { className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", - style: {}, + style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 }; } - // 헤더 높이를 최소화 (제목 영역만) - const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩) - const totalHeight = screenDimensions.height + headerHeight; + // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 + // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + const headerHeight = 60; // DialogHeader (타이틀 + 패딩) + const footerHeight = 52; // 연속 등록 모드 체크박스 영역 + + const totalHeight = screenDimensions.height + headerHeight + footerHeight; return { className: "overflow-hidden p-0", @@ -504,7 +519,7 @@ export const ScreenModal: React.FC = ({ className }) => { = ({ className }) => {
-
+
{loading ? (
@@ -568,7 +583,7 @@ export const ScreenModal: React.FC = ({ className }) => { return ( = ({ className }) => {
{ const isChecked = checked === true; - continuousModeRef.current = isChecked; + setContinuousMode(isChecked); localStorage.setItem("screenModal_continuousMode", String(isChecked)); - setForceUpdate((prev) => prev + 1); // 체크박스 UI 업데이트를 위한 강제 리렌더링 - // console.log("🔄 연속 모드 변경:", isChecked); + console.log("🔄 연속 모드 변경:", isChecked); }} />
- +
); }; @@ -994,6 +1024,16 @@ export const UnifiedPropertiesPanel: React.FC = ({ ); } + // 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인 + const definition = ComponentRegistry.getComponent(componentId); + if (definition?.configPanel) { + // 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출 + const configPanelContent = renderComponentConfigPanel(); + if (configPanelContent) { + return configPanelContent; + } + } + // 현재 웹타입의 기본 입력 타입 추출 const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; diff --git a/frontend/components/screen/table-options/FilterPanel.tsx b/frontend/components/screen/table-options/FilterPanel.tsx index 4688bb18..69395942 100644 --- a/frontend/components/screen/table-options/FilterPanel.tsx +++ b/frontend/components/screen/table-options/FilterPanel.tsx @@ -26,6 +26,7 @@ interface Props { isOpen: boolean; onClose: () => void; onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백 + screenId?: number; // 화면 ID 추가 } // 필터 타입별 연산자 @@ -69,7 +70,7 @@ interface ColumnFilterConfig { selectOptions?: Array<{ label: string; value: string }>; } -export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied }) => { +export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied, screenId }) => { const { getTable, selectedTableId } = useTableOptions(); const table = selectedTableId ? getTable(selectedTableId) : undefined; @@ -79,7 +80,10 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied // localStorage에서 저장된 필터 설정 불러오기 useEffect(() => { if (table?.columns && table?.tableName) { - const storageKey = `table_filters_${table.tableName}`; + // 화면별로 독립적인 필터 설정 저장 + const storageKey = screenId + ? `table_filters_${table.tableName}_screen_${screenId}` + : `table_filters_${table.tableName}`; const savedFilters = localStorage.getItem(storageKey); let filters: ColumnFilterConfig[]; @@ -192,9 +196,11 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied width: cf.width || 200, // 너비 포함 (기본 200px) })); - // localStorage에 저장 + // localStorage에 저장 (화면별로 독립적) if (table?.tableName) { - const storageKey = `table_filters_${table.tableName}`; + const storageKey = screenId + ? `table_filters_${table.tableName}_screen_${screenId}` + : `table_filters_${table.tableName}`; localStorage.setItem(storageKey, JSON.stringify(columnFilters)); } @@ -216,9 +222,11 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied setColumnFilters(clearedFilters); setSelectAll(false); - // localStorage에서 제거 + // localStorage에서 제거 (화면별로 독립적) if (table?.tableName) { - const storageKey = `table_filters_${table.tableName}`; + const storageKey = screenId + ? `table_filters_${table.tableName}_screen_${screenId}` + : `table_filters_${table.tableName}`; localStorage.removeItem(storageKey); } diff --git a/frontend/components/ui/resizable-dialog.tsx b/frontend/components/ui/resizable-dialog.tsx index bf50a1a1..74a53411 100644 --- a/frontend/components/ui/resizable-dialog.tsx +++ b/frontend/components/ui/resizable-dialog.tsx @@ -122,6 +122,10 @@ const ResizableDialogContent = React.forwardRef< // 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용) if (userStyle) { + console.log("🔍 userStyle 감지:", userStyle); + console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width); + console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height); + const styleWidth = typeof userStyle.width === 'string' ? parseInt(userStyle.width) : userStyle.width; @@ -129,24 +133,41 @@ const ResizableDialogContent = React.forwardRef< ? parseInt(userStyle.height) : userStyle.height; + console.log("📏 파싱된 크기:", { + styleWidth, + styleHeight, + "styleWidth truthy?": !!styleWidth, + "styleHeight truthy?": !!styleHeight, + minWidth, + maxWidth, + minHeight, + maxHeight + }); + if (styleWidth && styleHeight) { - return { + const finalSize = { width: Math.max(minWidth, Math.min(maxWidth, styleWidth)), height: Math.max(minHeight, Math.min(maxHeight, styleHeight)), }; + console.log("✅ userStyle 크기 사용:", finalSize); + return finalSize; + } else { + console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight }); } } - // 2순위: 현재 렌더링된 크기 사용 - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0) { - return { - width: Math.max(minWidth, Math.min(maxWidth, rect.width)), - height: Math.max(minHeight, Math.min(maxHeight, rect.height)), - }; - } - } + console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight }); + + // 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지) + // if (contentRef.current) { + // const rect = contentRef.current.getBoundingClientRect(); + // if (rect.width > 0 && rect.height > 0) { + // return { + // width: Math.max(minWidth, Math.min(maxWidth, rect.width)), + // height: Math.max(minHeight, Math.min(maxHeight, rect.height)), + // }; + // } + // } // 3순위: defaultWidth/defaultHeight 사용 return { width: defaultWidth, height: defaultHeight }; @@ -156,6 +177,58 @@ const ResizableDialogContent = React.forwardRef< const [isResizing, setIsResizing] = React.useState(false); const [resizeDirection, setResizeDirection] = React.useState(""); const [isInitialized, setIsInitialized] = React.useState(false); + + // userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시) + React.useEffect(() => { + // 1. localStorage에서 사용자가 리사이징한 크기 확인 + let savedSize: { width: number; height: number; userResized: boolean } | null = null; + + if (effectiveModalId && typeof window !== 'undefined') { + try { + const storageKey = `modal_size_${effectiveModalId}_${userId}`; + const saved = localStorage.getItem(storageKey); + + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.userResized) { + savedSize = { + width: Math.max(minWidth, Math.min(maxWidth, parsed.width)), + height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), + userResized: true, + }; + console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); + } + } + } catch (error) { + console.error("❌ 모달 크기 복원 실패:", error); + } + } + + // 2. 우선순위: 사용자 리사이징 > userStyle > 기본값 + if (savedSize && savedSize.userResized) { + // 사용자가 리사이징한 크기 우선 + setSize({ width: savedSize.width, height: savedSize.height }); + setUserResized(true); + console.log("✅ 사용자 리사이징 크기 적용:", savedSize); + } else if (userStyle && userStyle.width && userStyle.height) { + // 화면관리에서 설정한 크기 + const styleWidth = typeof userStyle.width === 'string' + ? parseInt(userStyle.width) + : userStyle.width; + const styleHeight = typeof userStyle.height === 'string' + ? parseInt(userStyle.height) + : userStyle.height; + + if (styleWidth && styleHeight) { + const newSize = { + width: Math.max(minWidth, Math.min(maxWidth, styleWidth)), + height: Math.max(minHeight, Math.min(maxHeight, styleHeight)), + }; + console.log("🔄 userStyle 크기 적용:", newSize); + setSize(newSize); + } + } + }, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]); const [lastModalId, setLastModalId] = React.useState(null); const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적 @@ -192,97 +265,98 @@ const ResizableDialogContent = React.forwardRef< }, [effectiveModalId, lastModalId, isInitialized]); // 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용) - React.useEffect(() => { - // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId }); - - if (!isInitialized) { - // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기) - // 여러 번 시도하여 contentRef가 준비될 때까지 대기 - let attempts = 0; - const maxAttempts = 10; - - const measureContent = () => { - attempts++; - - // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함) - let contentWidth = defaultWidth; - let contentHeight = defaultHeight; - - if (contentRef.current) { - // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거) - contentWidth = contentRef.current.scrollWidth || defaultWidth; - contentHeight = contentRef.current.scrollHeight || defaultHeight; - - // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight }); - } else { - // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight }); - - // contentRef가 아직 없으면 재시도 - if (attempts < maxAttempts) { - setTimeout(measureContent, 100); - return; - } - } - - // 패딩 추가 (p-6 * 2 = 48px) - const paddingAndMargin = 48; - const initialSize = getInitialSize(); - - // 내용 크기 기반 최소 크기 계산 - const contentBasedSize = { - width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))), - height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))), - }; - - // console.log("📐 내용 기반 크기:", contentBasedSize); - - // localStorage에서 저장된 크기 확인 - let finalSize = contentBasedSize; - - if (effectiveModalId && typeof window !== 'undefined') { - try { - const storageKey = `modal_size_${effectiveModalId}_${userId}`; - const saved = localStorage.getItem(storageKey); - - // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" }); - - if (saved) { - const parsed = JSON.parse(saved); - - // userResized 플래그 확인 - if (parsed.userResized) { - const savedSize = { - width: Math.max(minWidth, Math.min(maxWidth, parsed.width)), - height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), - }; - - // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); - - // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용 - // (사용자가 의도적으로 작게 만든 것을 존중) - finalSize = savedSize; - setUserResized(true); - - // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" }); - } else { - // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용"); - } - } else { - // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용"); - } - } catch (error) { - // console.error("❌ 모달 크기 복원 실패:", error); - } - } - - setSize(finalSize); - setIsInitialized(true); - }; - - // 첫 시도는 300ms 후에 시작 - setTimeout(measureContent, 300); - } - }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]); + // 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경 + // React.useEffect(() => { + // // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId }); + // + // if (!isInitialized) { + // // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기) + // // 여러 번 시도하여 contentRef가 준비될 때까지 대기 + // let attempts = 0; + // const maxAttempts = 10; + // + // const measureContent = () => { + // attempts++; + // + // // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함) + // let contentWidth = defaultWidth; + // let contentHeight = defaultHeight; + // + // // if (contentRef.current) { + // // // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거) + // // contentWidth = contentRef.current.scrollWidth || defaultWidth; + // // contentHeight = contentRef.current.scrollHeight || defaultHeight; + // // + // // // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight }); + // // } else { + // // // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight }); + // // + // // // contentRef가 아직 없으면 재시도 + // // if (attempts < maxAttempts) { + // // setTimeout(measureContent, 100); + // // return; + // // } + // // } + // + // // 패딩 추가 (p-6 * 2 = 48px) + // const paddingAndMargin = 48; + // const initialSize = getInitialSize(); + // + // // 내용 크기 기반 최소 크기 계산 + // const contentBasedSize = { + // width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))), + // height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))), + // }; + // + // // console.log("📐 내용 기반 크기:", contentBasedSize); + // + // // localStorage에서 저장된 크기 확인 + // let finalSize = contentBasedSize; + // + // if (effectiveModalId && typeof window !== 'undefined') { + // try { + // const storageKey = `modal_size_${effectiveModalId}_${userId}`; + // const saved = localStorage.getItem(storageKey); + // + // // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" }); + // + // if (saved) { + // const parsed = JSON.parse(saved); + // + // // userResized 플래그 확인 + // if (parsed.userResized) { + // const savedSize = { + // width: Math.max(minWidth, Math.min(maxWidth, parsed.width)), + // height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), + // }; + // + // // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); + // + // // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용 + // // (사용자가 의도적으로 작게 만든 것을 존중) + // finalSize = savedSize; + // setUserResized(true); + // + // // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" }); + // } else { + // // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용"); + // } + // } else { + // // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용"); + // } + // } catch (error) { + // // console.error("❌ 모달 크기 복원 실패:", error); + // } + // } + // + // setSize(finalSize); + // setIsInitialized(true); + // }; + // + // // 첫 시도는 300ms 후에 시작 + // setTimeout(measureContent, 300); + // } + // }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]); const startResize = (direction: string) => (e: React.MouseEvent) => { e.preventDefault(); @@ -433,6 +507,37 @@ const ResizableDialogContent = React.forwardRef< onMouseDown={startResize("nw")} /> + {/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */} + {userResized && ( + + )} + > => { + try { + const response = await apiClient.post( + `/admin/menus/${menuObjid}/copy`, + { + targetCompanyCode, + screenNameConfig + } + ); + return response.data; + } catch (error: any) { + console.error("❌ 메뉴 복사 실패:", error); + return { + success: false, + message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다", + errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR", + }; + } + }, }; + +/** + * 메뉴 복사 결과 + */ +export interface MenuCopyResult { + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + menuIdMap: Record; + screenIdMap: Record; + flowIdMap: Record; + warnings?: string[]; +} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 4c95ceb8..92fd89e8 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -107,6 +107,8 @@ export interface DynamicComponentRendererProps { onClose?: () => void; // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; + // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) + groupedData?: Record[]; selectedRowsData?: any[]; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) @@ -150,7 +152,10 @@ export const DynamicComponentRenderer: React.FC = const columnName = (component as any).columnName; // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 - if ((inputType === "category" || webType === "category") && tableName && columnName) { + // ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) + if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") { + // select-basic은 ComponentRegistry에서 처리하도록 아래로 통과 + } else if ((inputType === "category" || webType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; @@ -213,6 +218,16 @@ export const DynamicComponentRenderer: React.FC = // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); + // 🔍 디버깅: select-basic 조회 결과 확인 + if (componentType === "select-basic") { + console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { + componentType, + found: !!newComponent, + componentId: component.id, + componentConfig: component.componentConfig, + }); + } + if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { @@ -266,7 +281,17 @@ export const DynamicComponentRenderer: React.FC = // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 let currentValue; if (componentType === "modal-repeater-table") { - currentValue = formData?.[fieldName] || []; + // 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용 + currentValue = props.groupedData || formData?.[fieldName] || []; + + // 디버깅 로그 + console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", { + hasGroupedData: !!props.groupedData, + groupedDataLength: props.groupedData?.length || 0, + fieldName, + formDataValue: formData?.[fieldName], + finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0, + }); } else { currentValue = formData?.[fieldName] || ""; } @@ -367,6 +392,8 @@ export const DynamicComponentRenderer: React.FC = isPreview, // 디자인 모드 플래그 전달 - isPreview와 명확히 구분 isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, + // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) + groupedData: props.groupedData, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index 42baabdc..e3572e33 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -7,18 +7,23 @@ import { Button } from "@/components/ui/button"; import { useEntitySearch } from "../entity-search-input/useEntitySearch"; import { EntitySearchResult } from "../entity-search-input/types"; import { cn } from "@/lib/utils"; -import { AutocompleteSearchInputConfig, FieldMapping } from "./types"; +import { AutocompleteSearchInputConfig } from "./types"; +import { ComponentRendererProps } from "../../DynamicComponentRenderer"; -interface AutocompleteSearchInputProps extends Partial { +export interface AutocompleteSearchInputProps extends ComponentRendererProps { config?: AutocompleteSearchInputConfig; + tableName?: string; + displayField?: string; + valueField?: string; + searchFields?: string[]; filterCondition?: Record; - disabled?: boolean; - value?: any; - onChange?: (value: any, fullData?: any) => void; - className?: string; + placeholder?: string; + showAdditionalInfo?: boolean; + additionalFields?: string[]; } export function AutocompleteSearchInputComponent({ + component, config, tableName: propTableName, displayField: propDisplayField, @@ -29,9 +34,10 @@ export function AutocompleteSearchInputComponent({ disabled = false, value, onChange, - showAdditionalInfo: propShowAdditionalInfo, - additionalFields: propAdditionalFields, className, + isInteractive = false, + onFormDataChange, + formData, }: AutocompleteSearchInputProps) { // config prop 우선, 없으면 개별 prop 사용 const tableName = config?.tableName || propTableName || ""; @@ -39,8 +45,7 @@ export function AutocompleteSearchInputComponent({ const valueField = config?.valueField || propValueField || ""; const searchFields = config?.searchFields || propSearchFields || [displayField]; const placeholder = config?.placeholder || propPlaceholder || "검색..."; - const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false; - const additionalFields = config?.additionalFields || propAdditionalFields || []; + const [inputValue, setInputValue] = useState(""); const [isOpen, setIsOpen] = useState(false); const [selectedData, setSelectedData] = useState(null); @@ -52,15 +57,20 @@ export function AutocompleteSearchInputComponent({ filterCondition, }); + // formData에서 현재 값 가져오기 (isInteractive 모드) + const currentValue = isInteractive && formData && component?.columnName + ? formData[component.columnName] + : value; + // value가 변경되면 표시값 업데이트 useEffect(() => { - if (value && selectedData) { + if (currentValue && selectedData) { setInputValue(selectedData[displayField] || ""); - } else if (!value) { + } else if (!currentValue) { setInputValue(""); setSelectedData(null); } - }, [value, displayField]); + }, [currentValue, displayField, selectedData]); // 외부 클릭 감지 useEffect(() => { @@ -81,45 +91,61 @@ export function AutocompleteSearchInputComponent({ setIsOpen(true); }; - // 필드 자동 매핑 처리 - const applyFieldMappings = (item: EntitySearchResult) => { - if (!config?.enableFieldMapping || !config?.fieldMappings) { - return; - } - - config.fieldMappings.forEach((mapping: FieldMapping) => { - if (!mapping.sourceField || !mapping.targetField) { - return; - } - - const value = item[mapping.sourceField]; - - // DOM에서 타겟 필드 찾기 (id로 검색) - const targetElement = document.getElementById(mapping.targetField); - - if (targetElement) { - // input, textarea 등의 값 설정 - if ( - targetElement instanceof HTMLInputElement || - targetElement instanceof HTMLTextAreaElement - ) { - targetElement.value = value?.toString() || ""; - - // React의 change 이벤트 트리거 - const event = new Event("input", { bubbles: true }); - targetElement.dispatchEvent(event); - } - } - }); - }; - const handleSelect = (item: EntitySearchResult) => { setSelectedData(item); setInputValue(item[displayField] || ""); - onChange?.(item[valueField], item); - // 필드 자동 매핑 실행 - applyFieldMappings(item); + console.log("🔍 AutocompleteSearchInput handleSelect:", { + item, + valueField, + value: item[valueField], + config, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + columnName: component?.columnName, + }); + + // isInteractive 모드에서만 저장 + if (isInteractive && onFormDataChange) { + // 필드 매핑 처리 + if (config?.fieldMappings && Array.isArray(config.fieldMappings)) { + console.log("📋 필드 매핑 처리 시작:", config.fieldMappings); + + config.fieldMappings.forEach((mapping: any, index: number) => { + const targetField = mapping.targetField || mapping.targetColumn; + + console.log(` 매핑 ${index + 1}:`, { + sourceField: mapping.sourceField, + targetField, + label: mapping.label, + }); + + if (mapping.sourceField && targetField) { + const sourceValue = item[mapping.sourceField]; + + console.log(` 값: ${mapping.sourceField} = ${sourceValue}`); + + if (sourceValue !== undefined) { + console.log(` ✅ 저장: ${targetField} = ${sourceValue}`); + onFormDataChange(targetField, sourceValue); + } else { + console.warn(` ⚠️ sourceField "${mapping.sourceField}"의 값이 undefined입니다`); + } + } else { + console.warn(` ⚠️ 매핑 불완전: sourceField=${mapping.sourceField}, targetField=${targetField}`); + } + }); + } + + // 기본 필드 저장 (columnName이 설정된 경우) + if (component?.columnName) { + console.log(`💾 기본 필드 저장: ${component.columnName} = ${item[valueField]}`); + onFormDataChange(component.columnName, item[valueField]); + } + } + + // onChange 콜백 호출 (호환성) + onChange?.(item[valueField], item); setIsOpen(false); }; @@ -149,9 +175,9 @@ export function AutocompleteSearchInputComponent({ onFocus={handleInputFocus} placeholder={placeholder} disabled={disabled} - className="h-8 text-xs sm:h-10 sm:text-sm pr-16" + className="h-8 pr-16 text-xs sm:h-10 sm:text-sm !bg-background" /> -
+
{loading && ( )} @@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({ {/* 드롭다운 결과 */} {isOpen && (results.length > 0 || loading) && ( -
+
{loading && results.length === 0 ? (
- + 검색 중...
) : results.length === 0 ? ( @@ -189,37 +215,15 @@ export function AutocompleteSearchInputComponent({ key={index} type="button" onClick={() => handleSelect(item)} - className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors" + className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm" >
{item[displayField]}
- {additionalFields.length > 0 && ( -
- {additionalFields.map((field) => ( -
- {field}: {item[field] || "-"} -
- ))} -
- )} ))}
)}
)} - - {/* 추가 정보 표시 */} - {showAdditionalInfo && selectedData && additionalFields.length > 0 && ( -
- {additionalFields.map((field) => ( -
- {field}: - {selectedData[field] || "-"} -
- ))} -
- )}
); } - diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx index 96a212b1..e6942704 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx @@ -6,10 +6,9 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; -import { AutocompleteSearchInputConfig, FieldMapping, ValueFieldStorage } from "./types"; +import { AutocompleteSearchInputConfig } from "./types"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { cn } from "@/lib/utils"; @@ -24,83 +23,14 @@ export function AutocompleteSearchInputConfigPanel({ }: AutocompleteSearchInputConfigPanelProps) { const [localConfig, setLocalConfig] = useState(config); const [allTables, setAllTables] = useState([]); - const [tableColumns, setTableColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [targetTableColumns, setTargetTableColumns] = useState([]); const [isLoadingTables, setIsLoadingTables] = useState(false); - const [isLoadingColumns, setIsLoadingColumns] = useState(false); - const [openTableCombo, setOpenTableCombo] = useState(false); + const [isLoadingSourceColumns, setIsLoadingSourceColumns] = useState(false); + const [isLoadingTargetColumns, setIsLoadingTargetColumns] = useState(false); + const [openSourceTableCombo, setOpenSourceTableCombo] = useState(false); + const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false); const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false); - const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false); - const [openStorageTableCombo, setOpenStorageTableCombo] = useState(false); - const [openStorageColumnCombo, setOpenStorageColumnCombo] = useState(false); - const [storageTableColumns, setStorageTableColumns] = useState([]); - const [isLoadingStorageColumns, setIsLoadingStorageColumns] = useState(false); - - // 전체 테이블 목록 로드 - useEffect(() => { - const loadTables = async () => { - setIsLoadingTables(true); - try { - const response = await tableManagementApi.getTableList(); - if (response.success && response.data) { - setAllTables(response.data); - } - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setIsLoadingTables(false); - } - }; - loadTables(); - }, []); - - // 선택된 테이블의 컬럼 목록 로드 - useEffect(() => { - const loadColumns = async () => { - if (!localConfig.tableName) { - setTableColumns([]); - return; - } - - setIsLoadingColumns(true); - try { - const response = await tableManagementApi.getColumnList(localConfig.tableName); - if (response.success && response.data) { - setTableColumns(response.data.columns); - } - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - setTableColumns([]); - } finally { - setIsLoadingColumns(false); - } - }; - loadColumns(); - }, [localConfig.tableName]); - - // 저장 대상 테이블의 컬럼 목록 로드 - useEffect(() => { - const loadStorageColumns = async () => { - const storageTable = localConfig.valueFieldStorage?.targetTable; - if (!storageTable) { - setStorageTableColumns([]); - return; - } - - setIsLoadingStorageColumns(true); - try { - const response = await tableManagementApi.getColumnList(storageTable); - if (response.success && response.data) { - setStorageTableColumns(response.data.columns); - } - } catch (error) { - console.error("저장 테이블 컬럼 로드 실패:", error); - setStorageTableColumns([]); - } finally { - setIsLoadingStorageColumns(false); - } - }; - loadStorageColumns(); - }, [localConfig.valueFieldStorage?.targetTable]); useEffect(() => { setLocalConfig(config); @@ -112,52 +42,76 @@ export function AutocompleteSearchInputConfigPanel({ onConfigChange(newConfig); }; - const addSearchField = () => { - const fields = localConfig.searchFields || []; - updateConfig({ searchFields: [...fields, ""] }); - }; + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setIsLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables(response.data); + } + } catch (error) { + setAllTables([]); + } finally { + setIsLoadingTables(false); + } + }; + loadTables(); + }, []); - const updateSearchField = (index: number, value: string) => { - const fields = [...(localConfig.searchFields || [])]; - fields[index] = value; - updateConfig({ searchFields: fields }); - }; + // 외부 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!localConfig.tableName) { + setSourceTableColumns([]); + return; + } + setIsLoadingSourceColumns(true); + try { + const response = await tableManagementApi.getColumnList(localConfig.tableName); + if (response.success && response.data) { + setSourceTableColumns(response.data.columns); + } + } catch (error) { + setSourceTableColumns([]); + } finally { + setIsLoadingSourceColumns(false); + } + }; + loadColumns(); + }, [localConfig.tableName]); - const removeSearchField = (index: number) => { - const fields = [...(localConfig.searchFields || [])]; - fields.splice(index, 1); - updateConfig({ searchFields: fields }); - }; + // 저장 테이블 컬럼 로드 + useEffect(() => { + const loadTargetColumns = async () => { + if (!localConfig.targetTable) { + setTargetTableColumns([]); + return; + } + setIsLoadingTargetColumns(true); + try { + const response = await tableManagementApi.getColumnList(localConfig.targetTable); + if (response.success && response.data) { + setTargetTableColumns(response.data.columns); + } + } catch (error) { + setTargetTableColumns([]); + } finally { + setIsLoadingTargetColumns(false); + } + }; + loadTargetColumns(); + }, [localConfig.targetTable]); - const addAdditionalField = () => { - const fields = localConfig.additionalFields || []; - updateConfig({ additionalFields: [...fields, ""] }); - }; - - const updateAdditionalField = (index: number, value: string) => { - const fields = [...(localConfig.additionalFields || [])]; - fields[index] = value; - updateConfig({ additionalFields: fields }); - }; - - const removeAdditionalField = (index: number) => { - const fields = [...(localConfig.additionalFields || [])]; - fields.splice(index, 1); - updateConfig({ additionalFields: fields }); - }; - - // 필드 매핑 관리 함수 const addFieldMapping = () => { const mappings = localConfig.fieldMappings || []; updateConfig({ - fieldMappings: [ - ...mappings, - { sourceField: "", targetField: "", label: "" }, - ], + fieldMappings: [...mappings, { sourceField: "", targetField: "", label: "" }], }); }; - const updateFieldMapping = (index: number, updates: Partial) => { + const updateFieldMapping = (index: number, updates: any) => { const mappings = [...(localConfig.fieldMappings || [])]; mappings[index] = { ...mappings[index], ...updates }; updateConfig({ fieldMappings: mappings }); @@ -170,21 +124,22 @@ export function AutocompleteSearchInputConfigPanel({ }; return ( -
+
+ {/* 1. 외부 테이블 선택 */}
- - + + @@ -200,7 +155,7 @@ export function AutocompleteSearchInputConfigPanel({ value={table.tableName} onSelect={() => { updateConfig({ tableName: table.tableName }); - setOpenTableCombo(false); + setOpenSourceTableCombo(false); }} className="text-xs sm:text-sm" > @@ -216,13 +171,11 @@ export function AutocompleteSearchInputConfigPanel({ -

- 검색할 데이터가 저장된 테이블을 선택하세요 -

+ {/* 2. 표시 필드 선택 */}
- + @@ -244,7 +197,7 @@ export function AutocompleteSearchInputConfigPanel({ 필드를 찾을 수 없습니다. - {tableColumns.map((column) => ( + {sourceTableColumns.map((column) => ( -

- 사용자에게 보여줄 필드 (예: 거래처명) -

+ {/* 3. 저장 대상 테이블 선택 */}
- - + + - + - 필드를 찾을 수 없습니다. + 테이블을 찾을 수 없습니다. - {tableColumns.map((column) => ( + {allTables.map((table) => ( { - updateConfig({ valueField: column.columnName }); - setOpenValueFieldCombo(false); + updateConfig({ targetTable: table.tableName }); + setOpenTargetTableCombo(false); }} className="text-xs sm:text-sm" > - +
- {column.displayName || column.columnName} - {column.displayName && {column.columnName}} + {table.displayName || table.tableName} + {table.displayName && {table.tableName}}
))} @@ -316,11 +267,124 @@ export function AutocompleteSearchInputConfigPanel({
-

- 검색 테이블에서 가져올 값의 컬럼 (예: customer_code) -

+ {/* 4. 필드 매핑 */} +
+
+ + +
+ + {(localConfig.fieldMappings || []).length === 0 && ( +
+

+ 매핑 추가 버튼을 눌러 필드 매핑을 설정하세요 +

+
+ )} + +
+ {(localConfig.fieldMappings || []).map((mapping, index) => ( +
+
+ + 매핑 #{index + 1} + + +
+ +
+ + + updateFieldMapping(index, { label: e.target.value }) + } + placeholder="예: 거래처 코드" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + +
+ +
+ + +
+ + {mapping.sourceField && mapping.targetField && ( +
+

+ + {localConfig.tableName}.{mapping.sourceField} + + {" → "} + + {localConfig.targetTable}.{mapping.targetField} + +

+
+ )} +
+ ))} +
+
+ + {/* 플레이스홀더 */}
- {/* 값 필드 저장 위치 설정 */} -
-
-

값 필드 저장 위치 (고급)

-

- 위에서 선택한 "값 필드"의 데이터를 어느 테이블/컬럼에 저장할지 지정합니다. -
- 미설정 시 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 자동 저장됩니다. -

-
- - {/* 저장 테이블 선택 */} -
- - - - - - - - - - 테이블을 찾을 수 없습니다. - - {/* 기본값 옵션 */} - { - updateConfig({ - valueFieldStorage: { - ...localConfig.valueFieldStorage, - targetTable: undefined, - targetColumn: undefined, - }, - }); - setOpenStorageTableCombo(false); - }} - className="text-xs sm:text-sm" - > - -
- 기본값 - 화면의 연결 테이블 사용 -
-
- {allTables.map((table) => ( - { - updateConfig({ - valueFieldStorage: { - ...localConfig.valueFieldStorage, - targetTable: table.tableName, - targetColumn: undefined, // 테이블 변경 시 컬럼 초기화 - }, - }); - setOpenStorageTableCombo(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.displayName || table.tableName} - {table.displayName && {table.tableName}} -
-
- ))} -
-
-
-
-
-

- 값을 저장할 테이블 (기본값: 화면 연결 테이블) -

-
- - {/* 저장 컬럼 선택 */} - {localConfig.valueFieldStorage?.targetTable && ( -
- - - - - - - - - - 컬럼을 찾을 수 없습니다. - - {storageTableColumns.map((column) => ( - { - updateConfig({ - valueFieldStorage: { - ...localConfig.valueFieldStorage, - targetColumn: column.columnName, - }, - }); - setOpenStorageColumnCombo(false); - }} - className="text-xs sm:text-sm" - > - -
- {column.displayName || column.columnName} - {column.displayName && {column.columnName}} -
-
- ))} -
-
-
-
-
-

- 값을 저장할 컬럼명 + {/* 설정 요약 */} + {localConfig.tableName && localConfig.targetTable && (localConfig.fieldMappings || []).length > 0 && ( +

+

+ 설정 요약 +

+
+

+ 외부 테이블: {localConfig.tableName} +

+

+ 표시 필드: {localConfig.displayField} +

+

+ 저장 테이블: {localConfig.targetTable} +

+

+ 매핑 개수: {(localConfig.fieldMappings || []).length}개

-
- )} - - {/* 설명 박스 */} -
-

- 저장 위치 동작 -

-
- {localConfig.valueFieldStorage?.targetTable ? ( - <> -

- 선택한 값({localConfig.valueField})을 -

-

- - {localConfig.valueFieldStorage.targetTable} - {" "} - 테이블의{" "} - - {localConfig.valueFieldStorage.targetColumn || "(컬럼 미지정)"} - {" "} - 컬럼에 저장합니다. -

- - ) : ( -

기본값: 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 저장됩니다.

- )} -
-
-
- -
-
- - -
-
- {(localConfig.searchFields || []).map((field, index) => ( -
- - -
- ))} -
-
- -
-
- - - updateConfig({ showAdditionalInfo: checked }) - } - /> -
-
- - {localConfig.showAdditionalInfo && ( -
-
- - -
-
- {(localConfig.additionalFields || []).map((field, index) => ( -
- - -
- ))}
)} - - {/* 필드 자동 매핑 설정 */} -
-
-

필드 자동 매핑

-

- 선택한 항목의 필드를 화면의 다른 입력 필드에 자동으로 채워넣습니다 -

-
- -
-
- - - updateConfig({ enableFieldMapping: checked }) - } - /> -
-

- 활성화하면 항목 선택 시 설정된 필드들이 자동으로 채워집니다 -

-
- - {localConfig.enableFieldMapping && ( -
-
- - -
- -
- {(localConfig.fieldMappings || []).map((mapping, index) => ( -
-
- - 매핑 #{index + 1} - - -
- - {/* 표시명 */} -
- - - updateFieldMapping(index, { label: e.target.value }) - } - placeholder="예: 거래처명" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 이 매핑의 설명 (선택사항) -

-
- - {/* 소스 필드 (테이블의 컬럼) */} -
- - -

- 가져올 데이터의 컬럼명 -

-
- - {/* 타겟 필드 (화면의 input ID) */} -
- - - updateFieldMapping(index, { targetField: e.target.value }) - } - placeholder="예: customer_name_input" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 값을 채울 화면 컴포넌트의 ID (예: input의 id 속성) -

-
- - {/* 예시 설명 */} -
-

- {mapping.sourceField && mapping.targetField ? ( - <> - {mapping.label || "이 필드"}: 테이블의{" "} - - {mapping.sourceField} - {" "} - 값을 화면의{" "} - - {mapping.targetField} - {" "} - 컴포넌트에 자동으로 채웁니다 - - ) : ( - "소스 필드와 타겟 필드를 모두 선택하세요" - )} -

-
-
- ))} -
- - {/* 사용 안내 */} - {localConfig.fieldMappings && localConfig.fieldMappings.length > 0 && ( -
-

- 사용 방법 -

-
    -
  • 화면에서 이 검색 컴포넌트로 항목을 선택하면
  • -
  • 설정된 매핑에 따라 다른 입력 필드들이 자동으로 채워집니다
  • -
  • 타겟 필드 ID는 화면 디자이너에서 설정한 컴포넌트 ID와 일치해야 합니다
  • -
-
- )} -
- )} -
); } - diff --git a/frontend/lib/registry/components/autocomplete-search-input/types.ts b/frontend/lib/registry/components/autocomplete-search-input/types.ts index 802f27c7..85101e89 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/types.ts +++ b/frontend/lib/registry/components/autocomplete-search-input/types.ts @@ -27,5 +27,7 @@ export interface AutocompleteSearchInputConfig { // 필드 자동 매핑 설정 enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부 fieldMappings?: FieldMapping[]; // 매핑할 필드 목록 + // 저장 대상 테이블 (간소화 버전) + targetTable?: string; } diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index d1aff6de..2589026f 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -40,6 +40,7 @@ export function ConditionalContainerComponent({ componentId, style, className, + groupedData, // 🆕 그룹 데이터 }: ConditionalContainerProps) { console.log("🎯 ConditionalContainerComponent 렌더링!", { isDesignMode, @@ -177,6 +178,7 @@ export function ConditionalContainerComponent({ showBorder={showBorder} formData={formData} onFormDataChange={onFormDataChange} + groupedData={groupedData} /> ))}
@@ -196,6 +198,7 @@ export function ConditionalContainerComponent({ showBorder={showBorder} formData={formData} onFormDataChange={onFormDataChange} + groupedData={groupedData} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index c660a0a8..f77dbcdb 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react"; import { ConditionalSectionViewerProps } from "./types"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { cn } from "@/lib/utils"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; @@ -24,6 +25,7 @@ export function ConditionalSectionViewer({ showBorder = true, formData, onFormDataChange, + groupedData, // 🆕 그룹 데이터 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -135,13 +137,24 @@ export function ConditionalSectionViewer({ minHeight: "200px", }} > - {components.map((component) => ( - { + const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; + + return ( +
+ {}} + isInteractive={true} screenId={screenInfo?.id} tableName={screenInfo?.tableName} userId={userId} @@ -149,8 +162,11 @@ export function ConditionalSectionViewer({ companyCode={user?.companyCode} formData={formData} onFormDataChange={onFormDataChange} + groupedData={groupedData} /> - ))} +
+ ); + })}
)} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index 6f1964e9..0cf741b2 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -45,6 +45,7 @@ export interface ConditionalContainerProps { onChange?: (value: string) => void; formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; + groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) // 화면 편집기 관련 isDesignMode?: boolean; // 디자인 모드 여부 @@ -75,5 +76,6 @@ export interface ConditionalSectionViewerProps { // 폼 데이터 전달 formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; + groupedData?: Record[]; // 🆕 그룹 데이터 } diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 2003c5ef..3941a89f 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -9,9 +9,26 @@ import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./ import { useCalculation } from "./useCalculation"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { ComponentRendererProps } from "@/types/component"; -interface ModalRepeaterTableComponentProps extends Partial { +// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보 +export interface ModalRepeaterTableComponentProps extends ComponentRendererProps { config?: ModalRepeaterTableProps; + // ModalRepeaterTableProps의 개별 prop들도 지원 (호환성) + sourceTable?: string; + sourceColumns?: string[]; + sourceSearchFields?: string[]; + targetTable?: string; + modalTitle?: string; + modalButtonText?: string; + multiSelect?: boolean; + columns?: RepeaterColumnConfig[]; + calculationRules?: any[]; + value?: any[]; + onChange?: (newData: any[]) => void; + uniqueField?: string; + filterCondition?: Record; + companyCode?: string; } /** @@ -122,10 +139,25 @@ async function fetchReferenceValue( } export function ModalRepeaterTableComponent({ + // ComponentRendererProps (자동 전달) + component, + isDesignMode = false, + isSelected = false, + isInteractive = false, + onClick, + onDragStart, + onDragEnd, + className, + style, + formData, + onFormDataChange, + + // ModalRepeaterTable 전용 props config, sourceTable: propSourceTable, sourceColumns: propSourceColumns, sourceSearchFields: propSourceSearchFields, + targetTable: propTargetTable, modalTitle: propModalTitle, modalButtonText: propModalButtonText, multiSelect: propMultiSelect, @@ -136,36 +168,55 @@ export function ModalRepeaterTableComponent({ uniqueField: propUniqueField, filterCondition: propFilterCondition, companyCode: propCompanyCode, - className, + + ...props }: ModalRepeaterTableComponentProps) { + // ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합 + const componentConfig = { + ...config, + ...component?.config, + }; + // config prop 우선, 없으면 개별 prop 사용 - const sourceTable = config?.sourceTable || propSourceTable || ""; + const sourceTable = componentConfig?.sourceTable || propSourceTable || ""; + const targetTable = componentConfig?.targetTable || propTargetTable; // sourceColumns에서 빈 문자열 필터링 - const rawSourceColumns = config?.sourceColumns || propSourceColumns || []; - const sourceColumns = rawSourceColumns.filter((col) => col && col.trim() !== ""); + const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || []; + const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== ""); - const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || []; - const modalTitle = config?.modalTitle || propModalTitle || "항목 검색"; - const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색"; - const multiSelect = config?.multiSelect ?? propMultiSelect ?? true; - const calculationRules = config?.calculationRules || propCalculationRules || []; - const value = config?.value || propValue || []; - const onChange = config?.onChange || propOnChange || (() => {}); + const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || []; + const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색"; + const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색"; + const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true; + const calculationRules = componentConfig?.calculationRules || propCalculationRules || []; + + // ✅ value는 formData[columnName] 우선, 없으면 prop 사용 + const columnName = component?.columnName; + const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; + + // ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리) + const handleChange = (newData: any[]) => { + // 기존 onChange 콜백 호출 (호환성) + const externalOnChange = componentConfig?.onChange || propOnChange; + if (externalOnChange) { + externalOnChange(newData); + } + }; // uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경 - const rawUniqueField = config?.uniqueField || propUniqueField; + const rawUniqueField = componentConfig?.uniqueField || propUniqueField; const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info" ? "item_number" : rawUniqueField; - const filterCondition = config?.filterCondition || propFilterCondition || {}; - const companyCode = config?.companyCode || propCompanyCode; + const filterCondition = componentConfig?.filterCondition || propFilterCondition || {}; + const companyCode = componentConfig?.companyCode || propCompanyCode; const [modalOpen, setModalOpen] = useState(false); // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { - const configuredColumns = config?.columns || propColumns || []; + const configuredColumns = componentConfig?.columns || propColumns || []; if (configuredColumns.length > 0) { console.log("✅ 설정된 columns 사용:", configuredColumns); @@ -188,7 +239,7 @@ export function ModalRepeaterTableComponent({ console.warn("⚠️ columns와 sourceColumns 모두 비어있음!"); return []; - }, [config?.columns, propColumns, sourceColumns]); + }, [componentConfig?.columns, propColumns, sourceColumns]); // 초기 props 로깅 useEffect(() => { @@ -221,6 +272,93 @@ export function ModalRepeaterTableComponent({ }); }, [value]); + // 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너) + useEffect(() => { + const handleSaveRequest = async (event: Event) => { + const componentKey = columnName || component?.id || "modal_repeater_data"; + + console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", { + componentKey, + itemsCount: value.length, + hasOnFormDataChange: !!onFormDataChange, + columnName, + componentId: component?.id, + targetTable, + }); + + if (value.length === 0) { + console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음"); + return; + } + + // 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거) + console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", { + sourceColumns, + sourceTable, + targetTable, + sampleItem: value[0], + itemKeys: value[0] ? Object.keys(value[0]) : [], + }); + + const filteredData = value.map((item: any) => { + const filtered: Record = {}; + + Object.keys(item).forEach((key) => { + // sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼) + if (sourceColumns.includes(key)) { + console.log(` ⛔ ${key} 제외 (sourceColumn)`); + return; + } + // 메타데이터 필드도 제외 + if (key.startsWith("_")) { + console.log(` ⛔ ${key} 제외 (메타데이터)`); + return; + } + filtered[key] = item[key]; + }); + + return filtered; + }); + + console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", { + filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [], + sampleFilteredItem: filteredData[0], + }); + + // 🔥 targetTable 메타데이터를 배열 항목에 추가 + const dataWithTargetTable = targetTable + ? filteredData.map((item: any) => ({ + ...item, + _targetTable: targetTable, // 백엔드가 인식할 메타데이터 + })) + : filteredData; + + // ✅ CustomEvent의 detail에 데이터 추가 + if (event instanceof CustomEvent && event.detail) { + event.detail.formData[componentKey] = dataWithTargetTable; + console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", { + key: componentKey, + itemCount: dataWithTargetTable.length, + targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)", + sampleItem: dataWithTargetTable[0], + }); + } + + // 기존 onFormDataChange도 호출 (호환성) + if (onFormDataChange) { + onFormDataChange(componentKey, dataWithTargetTable); + console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료"); + } + }; + + // 저장 버튼 클릭 시 데이터 수집 + window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); + + return () => { + window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); + }; + }, [value, columnName, component?.id, onFormDataChange, targetTable]); + const { calculateRow, calculateAll } = useCalculation(calculationRules); // 초기 데이터에 계산 필드 적용 @@ -229,9 +367,10 @@ export function ModalRepeaterTableComponent({ const calculated = calculateAll(value); // 값이 실제로 변경된 경우만 업데이트 if (JSON.stringify(calculated) !== JSON.stringify(value)) { - onChange(calculated); + handleChange(calculated); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleAddItems = async (items: any[]) => { @@ -338,7 +477,8 @@ export function ModalRepeaterTableComponent({ const newData = [...value, ...calculatedItems]; console.log("✅ 최종 데이터:", newData.length, "개 항목"); - onChange(newData); + // ✅ 통합 onChange 호출 (formData 반영 포함) + handleChange(newData); }; const handleRowChange = (index: number, newRow: any) => { @@ -348,12 +488,16 @@ export function ModalRepeaterTableComponent({ // 데이터 업데이트 const newData = [...value]; newData[index] = calculatedRow; - onChange(newData); + + // ✅ 통합 onChange 호출 (formData 반영 포함) + handleChange(newData); }; const handleRowDelete = (index: number) => { const newData = value.filter((_, i) => i !== index); - onChange(newData); + + // ✅ 통합 onChange 호출 (formData 반영 포함) + handleChange(newData); }; // 컬럼명 -> 라벨명 매핑 생성 @@ -382,7 +526,7 @@ export function ModalRepeaterTableComponent({ diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx index 6362e1ce..6f61c052 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx @@ -7,40 +7,15 @@ import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent"; /** * ModalRepeaterTable 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + * ✅ 단순 전달만 수행 (TextInput 패턴 따름) */ export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = ModalRepeaterTableDefinition; render(): React.ReactElement { - // onChange 콜백을 명시적으로 전달 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleChange = (newValue: any[]) => { - console.log("🔄 ModalRepeaterTableRenderer onChange:", newValue.length, "개 항목"); - - // 컴포넌트 업데이트 - this.updateComponent({ value: newValue }); - - // 원본 onChange 콜백도 호출 (있다면) - if (this.props.onChange) { - this.props.onChange(newValue); - } - }; - - // renderer prop 제거 (불필요) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { onChange, ...restProps } = this.props; - - return ; + // ✅ props를 그대로 전달 (Component에서 모든 로직 처리) + return ; } - - /** - * 값 변경 처리 (레거시 메서드 - 호환성 유지) - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; } // 자동 등록 실행 diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 879cbce5..c9aa2cfd 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -118,10 +118,10 @@ export function RepeaterTable({ }; return ( -
-
+
+
- + - + {data.length === 0 ? (
# @@ -141,7 +141,7 @@ export function RepeaterTable({
= ({ menuObjid, // 🆕 메뉴 OBJID ...props }) => { + // 🚨 최초 렌더링 확인용 (테스트 후 제거) + console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", { + componentId: component.id, + componentType: (component as any).componentType, + columnName: (component as any).columnName, + "props.multiple": (props as any).multiple, + "componentConfig.multiple": componentConfig?.multiple, + }); + const [isOpen, setIsOpen] = useState(false); // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) const config = (props as any).webTypeConfig || componentConfig || {}; + // 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위 + const isMultiple = (props as any).multiple ?? config?.multiple ?? false; + + // 🔍 디버깅: config 및 multiple 확인 + useEffect(() => { + console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 =========="); + console.log(" 컴포넌트 ID:", component.id); + console.log(" 최종 isMultiple 값:", isMultiple); + console.log(" ----------------------------------------"); + console.log(" props.multiple:", (props as any).multiple); + console.log(" config.multiple:", config?.multiple); + console.log(" componentConfig.multiple:", componentConfig?.multiple); + console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple); + console.log(" ----------------------------------------"); + console.log(" config 전체:", config); + console.log(" componentConfig 전체:", componentConfig); + console.log(" component.componentConfig 전체:", component.componentConfig); + console.log(" ======================================="); + + // 다중선택이 활성화되었는지 알림 + if (isMultiple) { + console.log("✅ 다중선택 모드 활성화됨!"); + } else { + console.log("❌ 단일선택 모드 (다중선택 비활성화)"); + } + }, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]); + // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) const webType = component.componentConfig?.webType || "select"; @@ -62,8 +98,14 @@ const SelectBasicComponent: React.FC = ({ const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedLabel, setSelectedLabel] = useState(""); - // multiselect의 경우 배열로 관리 - const [selectedValues, setSelectedValues] = useState([]); + // multiselect의 경우 배열로 관리 (콤마 구분자로 파싱) + const [selectedValues, setSelectedValues] = useState(() => { + const initialValue = externalValue || config?.value || ""; + if (isMultiple && typeof initialValue === "string" && initialValue) { + return initialValue.split(",").map(v => v.trim()).filter(v => v); + } + return []; + }); // autocomplete의 경우 검색어 관리 const [searchQuery, setSearchQuery] = useState(""); @@ -96,6 +138,58 @@ const SelectBasicComponent: React.FC = ({ isFetching, } = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid); + // 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩 + const [categoryOptions, setCategoryOptions] = useState([]); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); + + useEffect(() => { + if (webType === "category" && component.tableName && component.columnName) { + console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", { + tableName: component.tableName, + columnName: component.columnName, + webType, + }); + + setIsLoadingCategories(true); + + import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => { + getCategoryValues(component.tableName!, component.columnName!) + .then((response) => { + console.log("🔍 [SelectBasic] 카테고리 API 응답:", response); + + if (response.success && response.data) { + console.log("🔍 [SelectBasic] 원본 데이터 샘플:", { + firstItem: response.data[0], + keys: response.data[0] ? Object.keys(response.data[0]) : [], + }); + + const activeValues = response.data.filter((v) => v.isActive !== false); + const options = activeValues.map((v) => ({ + value: v.valueCode, + label: v.valueLabel || v.valueCode, + })); + + console.log("✅ [SelectBasic] 카테고리 옵션 설정:", { + activeValuesCount: activeValues.length, + options, + sampleOption: options[0], + }); + + setCategoryOptions(options); + } else { + console.error("❌ [SelectBasic] 카테고리 응답 실패:", response); + } + }) + .catch((error) => { + console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error); + }) + .finally(() => { + setIsLoadingCategories(false); + }); + }); + } + }, [webType, component.tableName, component.columnName]); + // 디버깅: menuObjid가 제대로 전달되는지 확인 useEffect(() => { if (codeCategory && codeCategory !== "none") { @@ -113,11 +207,42 @@ const SelectBasicComponent: React.FC = ({ // 외부 value prop 변경 시 selectedValue 업데이트 useEffect(() => { const newValue = externalValue || config?.value || ""; - // 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리) - if (newValue !== selectedValue) { - setSelectedValue(newValue); + + console.log("🔍 [SelectBasic] 외부 값 변경 감지:", { + componentId: component.id, + columnName: (component as any).columnName, + isMultiple, + newValue, + selectedValue, + selectedValues, + externalValue, + "config.value": config?.value, + }); + + // 다중선택 모드인 경우 + if (isMultiple) { + if (typeof newValue === "string" && newValue) { + const values = newValue.split(",").map(v => v.trim()).filter(v => v); + const currentValuesStr = selectedValues.join(","); + + if (newValue !== currentValuesStr) { + console.log("✅ [SelectBasic] 다중선택 값 업데이트:", { + from: selectedValues, + to: values, + }); + setSelectedValues(values); + } + } else if (!newValue && selectedValues.length > 0) { + console.log("✅ [SelectBasic] 다중선택 값 초기화"); + setSelectedValues([]); + } + } else { + // 단일선택 모드인 경우 + if (newValue !== selectedValue) { + setSelectedValue(newValue); + } } - }, [externalValue, config?.value]); + }, [externalValue, config?.value, isMultiple]); // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) @@ -128,7 +253,7 @@ const SelectBasicComponent: React.FC = ({ useEffect(() => { const getAllOptions = () => { const configOptions = config.options || []; - return [...codeOptions, ...configOptions]; + return [...codeOptions, ...categoryOptions, ...configOptions]; }; const options = getAllOptions(); @@ -204,12 +329,24 @@ const SelectBasicComponent: React.FC = ({ // 모든 옵션 가져오기 const getAllOptions = () => { const configOptions = config.options || []; - return [...codeOptions, ...configOptions]; + return [...codeOptions, ...categoryOptions, ...configOptions]; }; const allOptions = getAllOptions(); const placeholder = componentConfig.placeholder || "선택하세요"; + // 🔍 디버깅: 최종 옵션 확인 + useEffect(() => { + if (webType === "category" && allOptions.length > 0) { + console.log("🔍 [SelectBasic] 최종 allOptions:", { + count: allOptions.length, + categoryOptionsCount: categoryOptions.length, + codeOptionsCount: codeOptions.length, + sampleOptions: allOptions.slice(0, 3), + }); + } + }, [webType, allOptions.length, categoryOptions.length, codeOptions.length]); + // DOM props에서 React 전용 props 필터링 const { component: _component, @@ -500,6 +637,96 @@ const SelectBasicComponent: React.FC = ({ } // select (기본 선택박스) + // 다중선택 모드인 경우 + if (isMultiple) { + return ( +
+
!isDesignMode && setIsOpen(true)} + style={{ + pointerEvents: isDesignMode ? "none" : "auto", + height: "100%" + }} + > + {selectedValues.map((val, idx) => { + const opt = allOptions.find((o) => o.value === val); + return ( + + {opt?.label || val} + + + ); + })} + {selectedValues.length === 0 && ( + {placeholder} + )} +
+ {isOpen && !isDesignMode && ( +
+ {(isLoadingCodes || isLoadingCategories) ? ( +
로딩 중...
+ ) : allOptions.length > 0 ? ( + allOptions.map((option, index) => { + const isSelected = selectedValues.includes(option.value); + return ( +
{ + const newVals = isSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + > +
+ {}} + className="h-4 w-4" + /> + {option.label || option.value} +
+
+ ); + }) + ) : ( +
옵션이 없습니다
+ )} +
+ )} +
+ ); + } + + // 단일선택 모드 return (
= ({ onChange, }) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => { - onChange({ [key]: value }); + // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호) + const newConfig = { ...config, [key]: value }; + onChange(newConfig); }; return ( @@ -67,6 +69,15 @@ export const SelectBasicConfigPanel: React.FC = ({ onCheckedChange={(checked) => handleChange("readonly", checked)} />
+ +
+ + handleChange("multiple", checked)} + /> +
); }; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 408934e2..80fb210a 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -73,6 +73,117 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>>({}); + // 🆕 추가 입력 필드별 자동 채우기 테이블 컬럼 상태 + const [autoFillTableColumns, setAutoFillTableColumns] = useState>>({}); + + // 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드) + const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + + // 🆕 원본 테이블 컬럼 로드 + useEffect(() => { + if (!config.sourceTable) { + setLoadedSourceTableColumns([]); + return; + } + + const loadColumns = async () => { + try { + console.log("🔍 원본 테이블 컬럼 로드:", config.sourceTable); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(config.sourceTable); + + if (response.success && response.data) { + const columns = response.data.columns || []; + setLoadedSourceTableColumns(columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + }))); + console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length); + } + } catch (error) { + console.error("❌ 원본 테이블 컬럼 로드 오류:", error); + } + }; + + loadColumns(); + }, [config.sourceTable]); + + // 🆕 대상 테이블 컬럼 로드 + useEffect(() => { + if (!config.targetTable) { + setLoadedTargetTableColumns([]); + return; + } + + const loadColumns = async () => { + try { + console.log("🔍 대상 테이블 컬럼 로드:", config.targetTable); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(config.targetTable); + + if (response.success && response.data) { + const columns = response.data.columns || []; + setLoadedTargetTableColumns(columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + }))); + console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length); + } + } catch (error) { + console.error("❌ 대상 테이블 컬럼 로드 오류:", error); + } + }; + + loadColumns(); + }, [config.targetTable]); + + // 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드 + useEffect(() => { + if (!localFields || localFields.length === 0) return; + + localFields.forEach((field, index) => { + if (field.autoFillFromTable && !autoFillTableColumns[index]) { + console.log(`🔍 [초기화] 필드 ${index}의 기존 테이블 컬럼 로드:`, field.autoFillFromTable); + loadAutoFillTableColumns(field.autoFillFromTable, index); + } + }); + }, []); // 초기 한 번만 실행 + + // 🆕 자동 채우기 테이블 선택 시 컬럼 로드 + const loadAutoFillTableColumns = async (tableName: string, fieldIndex: number) => { + if (!tableName) { + setAutoFillTableColumns(prev => ({ ...prev, [fieldIndex]: [] })); + return; + } + + try { + console.log(`🔍 [필드 ${fieldIndex}] 자동 채우기 테이블 컬럼 로드:`, tableName); + + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(tableName); + + if (response.success && response.data) { + const columns = response.data.columns || []; + setAutoFillTableColumns(prev => ({ + ...prev, + [fieldIndex]: columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + })) + })); + console.log(`✅ [필드 ${fieldIndex}] 컬럼 로드 성공:`, columns.length); + } else { + console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 실패:`, response); + } + } catch (error) { + console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 오류:`, error); + } + }; + // 🆕 소스 테이블 선택 시 컬럼 로드 const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => { try { @@ -180,7 +291,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { - onChange({ [key]: value }); + // 🔧 기존 config와 병합하여 다른 속성 유지 + onChange({ ...config, [key]: value }); }; const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => { @@ -261,15 +373,19 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + // 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업 + const columns = loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns; const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); - return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName)); - }, [sourceTableColumns, displayColumns, localFields]); + return columns.filter((col) => !usedColumns.has(col.columnName)); + }, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]); // 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록 const availableTargetColumns = useMemo(() => { + // 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업 + const columns = loadedTargetTableColumns.length > 0 ? loadedTargetTableColumns : targetTableColumns; const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); - return targetTableColumns.filter((col) => !usedColumns.has(col.columnName)); - }, [targetTableColumns, displayColumns, localFields]); + return columns.filter((col) => !usedColumns.has(col.columnName)); + }, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]); // 🆕 원본 테이블 필터링 const filteredSourceTables = useMemo(() => { @@ -403,7 +519,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {selectedSourceTableLabel} @@ -677,15 +792,66 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {/* 테이블명 입력 */} - updateField(index, { autoFillFromTable: e.target.value })} - placeholder="비워두면 주 데이터 (예: item_price)" - className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" - /> + {/* 테이블 선택 드롭다운 */} + + + + + + + + 테이블을 찾을 수 없습니다. + + { + updateField(index, { autoFillFromTable: undefined, autoFillFrom: undefined }); + setAutoFillTableColumns(prev => ({ ...prev, [index]: [] })); + }} + className="text-[10px] sm:text-xs" + > + + 원본 테이블 ({config.sourceTable || "미설정"}) + + {allTables.map((table) => ( + { + updateField(index, { autoFillFromTable: value, autoFillFrom: undefined }); + loadAutoFillTableColumns(value, index); + }} + className="text-[10px] sm:text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + +

- 다른 테이블에서 가져올 경우 테이블명 입력 + 다른 테이블에서 가져올 경우 테이블 선택

{/* 필드 선택 */} @@ -696,16 +862,26 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {field.autoFillFrom - ? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom - : "필드 선택 안 함"} + {(() => { + if (!field.autoFillFrom) return "필드 선택 안 함"; + + // 선택된 테이블의 컬럼에서 찾기 + const columns = field.autoFillFromTable + ? (autoFillTableColumns[index] || []) + : (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns); + + const found = columns.find(c => c.columnName === field.autoFillFrom); + return found?.columnLabel || field.autoFillFrom; + })()} - 원본 테이블을 먼저 선택하세요. + + {field.autoFillFromTable ? "컬럼을 찾을 수 없습니다" : "원본 테이블을 먼저 선택하세요"} + 선택 안 함 - {sourceTableColumns.map((column) => ( - updateField(index, { autoFillFrom: column.columnName })} - className="text-[10px] sm:text-xs" - > - -
-
{column.columnLabel}
-
{column.columnName}
-
-
- ))} + {(() => { + // 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼 + const columns = field.autoFillFromTable + ? (autoFillTableColumns[index] || []) + : (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns); + + return columns.map((column) => ( + updateField(index, { autoFillFrom: value })} + className="text-[10px] sm:text-xs" + > + +
+
{column.columnLabel || column.columnName}
+ {column.dataType &&
{column.dataType}
} +
+
+ )); + })()}
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index f59a16e6..9f88e290 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC {/* 요약 표시 설정 (LIST 모드에서만) */} - {config.rightPanel?.displayMode === "list" && ( + {(config.rightPanel?.displayMode || "list") === "list" && (
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 74d81211..12bdb7d1 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -148,7 +148,7 @@ export interface TableListComponentProps { tableName?: string; onRefresh?: () => void; onClose?: () => void; - screenId?: string; + screenId?: number | string; // 화면 ID (필터 설정 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용) onSelectedRowsChange?: ( selectedRows: any[], @@ -183,6 +183,7 @@ export const TableListComponent: React.FC = ({ refreshKey, tableName, userId, + screenId, // 화면 ID 추출 }) => { // ======================================== // 설정 및 스타일 @@ -1227,8 +1228,9 @@ export const TableListComponent: React.FC = ({ } // 체크박스 컬럼 (나중에 위치 결정) + // 기본값: enabled가 undefined면 true로 처리 let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled) { + if (tableConfig.checkbox?.enabled ?? true) { checkboxCol = { columnName: "__checkbox__", displayName: "", @@ -1257,7 +1259,7 @@ export const TableListComponent: React.FC = ({ // 체크박스를 맨 앞 또는 맨 뒤에 추가 if (checkboxCol) { - if (tableConfig.checkbox.position === "right") { + if (tableConfig.checkbox?.position === "right") { cols = [...cols, checkboxCol]; } else { cols = [checkboxCol, ...cols]; @@ -1423,33 +1425,73 @@ export const TableListComponent: React.FC = ({ ); } - // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원) + // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) if (inputType === "category") { if (!value) return ""; const mapping = categoryMappings[column.columnName]; - const categoryData = mapping?.[String(value)]; + const { Badge } = require("@/components/ui/badge"); - // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 - const displayLabel = categoryData?.label || String(value); - const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 + // 다중 값 처리: 콤마로 구분된 값들을 분리 + const valueStr = String(value); + const values = valueStr.includes(",") + ? valueStr.split(",").map(v => v.trim()).filter(v => v) + : [valueStr]; - // 배지 없음 옵션: color가 "none"이면 텍스트만 표시 - if (displayColor === "none") { - return {displayLabel}; + // 단일 값인 경우 (기존 로직) + if (values.length === 1) { + const categoryData = mapping?.[values[0]]; + const displayLabel = categoryData?.label || values[0]; + const displayColor = categoryData?.color || "#64748b"; + + if (displayColor === "none") { + return {displayLabel}; + } + + return ( + + {displayLabel} + + ); } - const { Badge } = require("@/components/ui/badge"); + // 다중 값인 경우: 여러 배지 렌더링 return ( - - {displayLabel} - +
+ {values.map((val, idx) => { + const categoryData = mapping?.[val]; + const displayLabel = categoryData?.label || val; + const displayColor = categoryData?.color || "#64748b"; + + if (displayColor === "none") { + return ( + + {displayLabel} + {idx < values.length - 1 && ", "} + + ); + } + + return ( + + {displayLabel} + + ); + })} +
); } @@ -1535,17 +1577,21 @@ export const TableListComponent: React.FC = ({ // useEffect 훅 // ======================================== - // 필터 설정 localStorage 키 생성 + // 필터 설정 localStorage 키 생성 (화면별로 독립적) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return `tableList_filterSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); + return screenId + ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` + : `tableList_filterSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable, screenId]); - // 그룹 설정 localStorage 키 생성 + // 그룹 설정 localStorage 키 생성 (화면별로 독립적) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return `tableList_groupSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); + return screenId + ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` + : `tableList_groupSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable, screenId]); // 저장된 필터 설정 불러오기 useEffect(() => { diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 6d76cfc8..c5ed9aaa 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -269,7 +269,9 @@ export const TableListConfigPanel: React.FC = ({ // }); const parentValue = config[parentKey] as any; + // 전체 config와 병합하여 다른 속성 유지 const newConfig = { + ...config, [parentKey]: { ...parentValue, [childKey]: value, @@ -754,6 +756,52 @@ export const TableListConfigPanel: React.FC = ({
+ {/* 체크박스 설정 */} +
+
+

체크박스 설정

+
+
+
+
+ handleNestedChange("checkbox", "enabled", checked)} + /> + +
+ + {config.checkbox?.enabled && ( + <> +
+ handleNestedChange("checkbox", "selectAll", checked)} + /> + +
+ +
+ + +
+ + )} +
+
+ {/* 가로 스크롤 및 컬럼 고정 */}
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index 01906c21..0416c4b3 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -12,6 +12,14 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel"; import { TableFilter } from "@/types/table-options"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +interface PresetFilter { + id: string; + columnName: string; + columnLabel: string; + filterType: "text" | "number" | "date" | "select"; + width?: number; +} + interface TableSearchWidgetProps { component: { id: string; @@ -25,6 +33,8 @@ interface TableSearchWidgetProps { componentConfig?: { autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부 showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부 + filterMode?: "dynamic" | "preset"; // 필터 모드 + presetFilters?: PresetFilter[]; // 고정 필터 목록 }; }; screenId?: number; // 화면 ID @@ -63,6 +73,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true; const showTableSelector = component.componentConfig?.showTableSelector ?? true; + const filterMode = component.componentConfig?.filterMode ?? "dynamic"; + const presetFilters = component.componentConfig?.presetFilters ?? []; // Map을 배열로 변환 const tableList = Array.from(registeredTables.values()); @@ -77,41 +89,58 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table } }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); - // 현재 테이블의 저장된 필터 불러오기 + // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) useEffect(() => { - if (currentTable?.tableName) { - const storageKey = `table_filters_${currentTable.tableName}`; - const savedFilters = localStorage.getItem(storageKey); + if (!currentTable?.tableName) return; - if (savedFilters) { - try { - const parsed = JSON.parse(savedFilters) as Array<{ - columnName: string; - columnLabel: string; - inputType: string; - enabled: boolean; - filterType: "text" | "number" | "date" | "select"; - width?: number; - }>; + // 고정 모드: presetFilters를 activeFilters로 설정 + if (filterMode === "preset") { + const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({ + columnName: f.columnName, + operator: "contains", + value: "", + filterType: f.filterType, + width: f.width || 200, + })); + setActiveFilters(activeFiltersList); + return; + } - // enabled된 필터들만 activeFilters로 설정 - const activeFiltersList: TableFilter[] = parsed - .filter((f) => f.enabled) - .map((f) => ({ - columnName: f.columnName, - operator: "contains", - value: "", - filterType: f.filterType, - width: f.width || 200, // 저장된 너비 포함 - })); + // 동적 모드: 화면별로 독립적인 필터 설정 불러오기 + const storageKey = screenId + ? `table_filters_${currentTable.tableName}_screen_${screenId}` + : `table_filters_${currentTable.tableName}`; + const savedFilters = localStorage.getItem(storageKey); - setActiveFilters(activeFiltersList); - } catch (error) { - console.error("저장된 필터 불러오기 실패:", error); - } + if (savedFilters) { + try { + const parsed = JSON.parse(savedFilters) as Array<{ + columnName: string; + columnLabel: string; + inputType: string; + enabled: boolean; + filterType: "text" | "number" | "date" | "select"; + width?: number; + }>; + + // enabled된 필터들만 activeFilters로 설정 + const activeFiltersList: TableFilter[] = parsed + .filter((f) => f.enabled) + .map((f) => ({ + columnName: f.columnName, + operator: "contains", + value: "", + filterType: f.filterType, + width: f.width || 200, // 저장된 너비 포함 + })); + + setActiveFilters(activeFiltersList); + } catch (error) { + console.error("저장된 필터 불러오기 실패:", error); } } - }, [currentTable?.tableName]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]); // select 옵션 초기 로드 (한 번만 실행, 이후 유지) useEffect(() => { @@ -362,7 +391,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table {/* 필터가 없을 때는 빈 공간 */} {activeFilters.length === 0 &&
} - {/* 오른쪽: 데이터 건수 + 설정 버튼들 */} + {/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
{/* 데이터 건수 표시 */} {currentTable?.dataCount !== undefined && ( @@ -371,38 +400,43 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
)} - + {/* 동적 모드일 때만 설정 버튼들 표시 */} + {filterMode === "dynamic" && ( + <> + - + - + + + )}
{/* 패널들 */} @@ -411,6 +445,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table isOpen={filterOpen} onClose={() => setFilterOpen(false)} onFiltersApplied={(filters) => setActiveFilters(filters)} + screenId={screenId} /> setGroupingOpen(false)} />
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx index 646fd3c4..8c4ab6a1 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx @@ -3,27 +3,126 @@ import React, { useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, X } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; interface TableSearchWidgetConfigPanelProps { - component: any; - onUpdateProperty: (property: string, value: any) => void; + component?: any; // 레거시 지원 + config?: any; // 새 인터페이스 + onUpdateProperty?: (property: string, value: any) => void; // 레거시 지원 + onChange?: (newConfig: any) => void; // 새 인터페이스 + tables?: any[]; // 화면의 테이블 정보 +} + +interface PresetFilter { + id: string; + columnName: string; + columnLabel: string; + filterType: "text" | "number" | "date" | "select"; + width?: number; } export function TableSearchWidgetConfigPanel({ component, + config, onUpdateProperty, + onChange, + tables = [], }: TableSearchWidgetConfigPanelProps) { + // 레거시와 새 인터페이스 모두 지원 + const currentConfig = config || component?.componentConfig || {}; + const updateConfig = onChange || ((key: string, value: any) => { + if (onUpdateProperty) { + onUpdateProperty(`componentConfig.${key}`, value); + } + }); + + // 첫 번째 테이블의 컬럼 목록 가져오기 + const availableColumns = tables.length > 0 && tables[0].columns ? tables[0].columns : []; + + // inputType에서 filterType 추출 헬퍼 함수 + const getFilterTypeFromInputType = (inputType: string): "text" | "number" | "date" | "select" => { + if (inputType.includes("number") || inputType.includes("decimal") || inputType.includes("integer")) { + return "number"; + } + if (inputType.includes("date") || inputType.includes("time")) { + return "date"; + } + if (inputType.includes("select") || inputType.includes("dropdown") || inputType.includes("code") || inputType.includes("category")) { + return "select"; + } + return "text"; + }; + const [localAutoSelect, setLocalAutoSelect] = useState( - component.componentConfig?.autoSelectFirstTable ?? true + currentConfig.autoSelectFirstTable ?? true ); const [localShowSelector, setLocalShowSelector] = useState( - component.componentConfig?.showTableSelector ?? true + currentConfig.showTableSelector ?? true + ); + const [localFilterMode, setLocalFilterMode] = useState<"dynamic" | "preset">( + currentConfig.filterMode ?? "dynamic" + ); + const [localPresetFilters, setLocalPresetFilters] = useState( + currentConfig.presetFilters ?? [] ); useEffect(() => { - setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true); - setLocalShowSelector(component.componentConfig?.showTableSelector ?? true); - }, [component.componentConfig]); + setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true); + setLocalShowSelector(currentConfig.showTableSelector ?? true); + setLocalFilterMode(currentConfig.filterMode ?? "dynamic"); + setLocalPresetFilters(currentConfig.presetFilters ?? []); + }, [currentConfig]); + + // 설정 업데이트 헬퍼 + const handleUpdate = (key: string, value: any) => { + if (onChange) { + // 새 인터페이스: 전체 config 업데이트 + onChange({ ...currentConfig, [key]: value }); + } else if (onUpdateProperty) { + // 레거시: 개별 속성 업데이트 + onUpdateProperty(`componentConfig.${key}`, value); + } + }; + + // 필터 추가 + const addFilter = () => { + const newFilter: PresetFilter = { + id: `filter_${Date.now()}`, + columnName: "", + columnLabel: "", + filterType: "text", + width: 200, + }; + const updatedFilters = [...localPresetFilters, newFilter]; + setLocalPresetFilters(updatedFilters); + handleUpdate("presetFilters", updatedFilters); + }; + + // 필터 삭제 + const removeFilter = (id: string) => { + const updatedFilters = localPresetFilters.filter((f) => f.id !== id); + setLocalPresetFilters(updatedFilters); + handleUpdate("presetFilters", updatedFilters); + }; + + // 필터 업데이트 + const updateFilter = (id: string, field: keyof PresetFilter, value: any) => { + const updatedFilters = localPresetFilters.map((f) => + f.id === id ? { ...f, [field]: value } : f + ); + setLocalPresetFilters(updatedFilters); + handleUpdate("presetFilters", updatedFilters); + }; return (
@@ -41,7 +140,7 @@ export function TableSearchWidgetConfigPanel({ checked={localAutoSelect} onCheckedChange={(checked) => { setLocalAutoSelect(checked as boolean); - onUpdateProperty("componentConfig.autoSelectFirstTable", checked); + handleUpdate("autoSelectFirstTable", checked); }} />
+ {/* 필터 모드 선택 */} +
+ + { + setLocalFilterMode(value); + handleUpdate("filterMode", value); + }} + > +
+ + +
+
+ + +
+
+
+ + {/* 고정 모드일 때만 필터 설정 UI 표시 */} + {localFilterMode === "preset" && ( +
+
+ + +
+ + {localPresetFilters.length === 0 ? ( +
+ 필터가 없습니다. 필터 추가 버튼을 클릭하세요. +
+ ) : ( +
+ {localPresetFilters.map((filter) => ( +
+
+ + +
+ + {/* 컬럼 선택 */} +
+ + {availableColumns.length > 0 ? ( + + ) : ( + updateFilter(filter.id, "columnName", e.target.value)} + placeholder="예: customer_name" + className="h-7 text-xs" + /> + )} + {filter.columnLabel && ( +

+ 표시명: {filter.columnLabel} +

+ )} +
+ + {/* 필터 타입 */} +
+ + +
+ + {/* 너비 */} +
+ + updateFilter(filter.id, "width", parseInt(e.target.value))} + placeholder="200" + className="h-7 text-xs" + min={100} + max={500} + /> +
+
+ ))} +
+ )} +
+ )} +

참고사항:

  • 테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다
  • 여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다
  • -
  • 선택한 테이블의 컬럼 정보가 자동으로 로드됩니다
  • + {localFilterMode === "dynamic" ? ( +
  • 사용자가 필터 설정 버튼을 클릭하여 원하는 필터를 선택합니다
  • + ) : ( +
  • 고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시됩니다
  • + )}
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx index 6fe47cc7..21e589ef 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx @@ -2,8 +2,8 @@ import React from "react"; import { TableSearchWidget } from "./TableSearchWidget"; export class TableSearchWidgetRenderer { - static render(component: any) { - return ; + static render(component: any, props?: any) { + return ; } } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index d6ddd96c..3b9b9d9e 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -41,7 +41,8 @@ export interface ButtonActionConfig { // 모달/팝업 관련 modalTitle?: string; - modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음) + modalTitleBlocks?: Array<{ + // 🆕 블록 기반 제목 (우선순위 높음) id: string; type: "text" | "field"; value: string; // type=text: 텍스트 내용, type=field: 컬럼명 @@ -88,6 +89,12 @@ export interface ButtonActionConfig { // 코드 병합 관련 mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code") mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true) + + // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) + editMode?: "modal" | "navigate" | "inline"; // 편집 모드 + editModalTitle?: string; // 편집 모달 제목 + editModalDescription?: string; // 편집 모달 설명 + groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"]) } /** @@ -345,11 +352,11 @@ export class ButtonActionExecutor { // console.log("👤 [buttonActions] 사용자 정보:", { // userId: context.userId, // userName: context.userName, - // companyCode: context.companyCode, // ✅ 회사 코드 - // formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 - // formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 + // companyCode: context.companyCode, + // formDataWriter: formData.writer, + // formDataCompanyCode: formData.company_code, // defaultWriterValue: writerValue, - // companyCodeValue, // ✅ 최종 회사 코드 값 + // companyCodeValue, // }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) @@ -1256,14 +1263,6 @@ export class ButtonActionExecutor { // 플로우 선택 데이터 우선 사용 let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; - console.log("🔍 handleEdit - 데이터 소스 확인:", { - hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), - flowSelectedDataLength: flowSelectedData?.length || 0, - hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), - selectedRowsDataLength: selectedRowsData?.length || 0, - dataToEditLength: dataToEdit?.length || 0, - }); - // 선택된 데이터가 없는 경우 if (!dataToEdit || dataToEdit.length === 0) { toast.error("수정할 항목을 선택해주세요."); @@ -1276,26 +1275,15 @@ export class ButtonActionExecutor { return false; } - console.log(`📝 편집 액션 실행: ${dataToEdit.length}개 항목`, { - dataToEdit, - targetScreenId: config.targetScreenId, - editMode: config.editMode, - }); - if (dataToEdit.length === 1) { // 단일 항목 편집 const rowData = dataToEdit[0]; - console.log("📝 단일 항목 편집:", rowData); await this.openEditForm(config, rowData, context); } else { // 다중 항목 편집 - 현재는 단일 편집만 지원 toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요."); return false; - - // TODO: 향후 다중 편집 지원 - // console.log("📝 다중 항목 편집:", selectedRowsData); - // this.openBulkEditForm(config, selectedRowsData, context); } return true; @@ -1329,7 +1317,7 @@ export class ButtonActionExecutor { default: // 기본값: 모달 - this.openEditModal(config, rowData, context); + await this.openEditModal(config, rowData, context); } } @@ -1341,11 +1329,17 @@ export class ButtonActionExecutor { rowData: any, context: ButtonActionContext, ): Promise { - console.log("🎭 편집 모달 열기:", { - targetScreenId: config.targetScreenId, - modalSize: config.modalSize, - rowData, - }); + const { groupByColumns = [] } = config; + + // PK 값 추출 (우선순위: id > ID > 첫 번째 필드) + let primaryKeyValue: any; + if (rowData.id !== undefined && rowData.id !== null) { + primaryKeyValue = rowData.id; + } else if (rowData.ID !== undefined && rowData.ID !== null) { + primaryKeyValue = rowData.ID; + } else { + primaryKeyValue = Object.values(rowData)[0]; + } // 1. config에 editModalDescription이 있으면 우선 사용 let description = config.editModalDescription || ""; @@ -1360,7 +1354,7 @@ export class ButtonActionExecutor { } } - // 모달 열기 이벤트 발생 + // 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리) const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, @@ -1368,16 +1362,15 @@ export class ButtonActionExecutor { description: description, modalSize: config.modalSize || "lg", editData: rowData, + groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 + tableName: context.tableName, // 🆕 테이블명 전달 onSave: () => { - // 저장 후 테이블 새로고침 - console.log("💾 편집 저장 완료 - 테이블 새로고침"); context.onRefresh?.(); }, }, }); window.dispatchEvent(modalEvent); - // 편집 모달 열기는 조용히 처리 (토스트 없음) } /**