diff --git a/PLAN.MD b/PLAN.MD index 7c3b1007..787bef69 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러 ## 진행 상태 - [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 + diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md index 570dd789..1fe76c86 100644 --- a/PROJECT_STATUS_2025_11_20.md +++ b/PROJECT_STATUS_2025_11_20.md @@ -55,3 +55,4 @@ - `backend-node/src/routes/digitalTwinRoutes.ts` - `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index be51e70e..fc69cdb1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 -import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 @@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 -app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드 // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제) app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b3ecbffb..da0ea772 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"; /** * 관리자 메뉴 목록 조회 @@ -1096,7 +1097,11 @@ export async function saveMenu( let requestCompanyCode = menuData.companyCode || menuData.company_code; // "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용 - if (requestCompanyCode === "none" || requestCompanyCode === "" || !requestCompanyCode) { + if ( + requestCompanyCode === "none" || + requestCompanyCode === "" || + !requestCompanyCode + ) { requestCompanyCode = undefined; } @@ -1251,7 +1256,8 @@ export async function updateMenu( } } - const requestCompanyCode = menuData.companyCode || menuData.company_code || currentMenu.company_code; + const requestCompanyCode = + menuData.companyCode || menuData.company_code || currentMenu.company_code; // company_code 변경 시도하는 경우 권한 체크 if (requestCompanyCode !== currentMenu.company_code) { @@ -1267,7 +1273,10 @@ export async function updateMenu( } } // 회사 관리자는 자기 회사로만 변경 가능 - else if (userCompanyCode !== "*" && requestCompanyCode !== userCompanyCode) { + else if ( + userCompanyCode !== "*" && + requestCompanyCode !== userCompanyCode + ) { res.status(403).json({ success: false, message: "해당 회사로 변경할 권한이 없습니다.", @@ -1323,7 +1332,7 @@ export async function updateMenu( if (!menuUrl) { await query( `UPDATE screen_menu_assignments - SET is_active = 'N', updated_date = NOW() + SET is_active = 'N' WHERE menu_objid = $1 AND company_code = $2`, [Number(menuId), companyCode] ); @@ -1492,8 +1501,13 @@ export async function deleteMenusBatch( ); // 권한 체크: 공통 메뉴 포함 여부 확인 - const hasCommonMenu = menusToDelete.some((menu: any) => menu.company_code === "*"); - if (hasCommonMenu && (userCompanyCode !== "*" || userType !== "SUPER_ADMIN")) { + const hasCommonMenu = menusToDelete.some( + (menu: any) => menu.company_code === "*" + ); + if ( + hasCommonMenu && + (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") + ) { res.status(403).json({ success: false, message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.", @@ -1505,7 +1519,8 @@ export async function deleteMenusBatch( // 회사 관리자는 자기 회사 메뉴만 삭제 가능 if (userCompanyCode !== "*") { const unauthorizedMenus = menusToDelete.filter( - (menu: any) => menu.company_code !== userCompanyCode && menu.company_code !== "*" + (menu: any) => + menu.company_code !== userCompanyCode && menu.company_code !== "*" ); if (unauthorizedMenus.length > 0) { res.status(403).json({ @@ -2673,7 +2688,10 @@ export const getCompanyByCode = async ( res.status(200).json(response); } catch (error) { - logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode }); + logger.error("회사 정보 조회 실패", { + error, + companyCode: req.params.companyCode, + }); res.status(500).json({ success: false, message: "회사 정보 조회 중 오류가 발생했습니다.", @@ -2739,7 +2757,9 @@ export const updateCompany = async ( // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) if (business_registration_number && business_registration_number.trim()) { // 유효성 검증 - const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + const businessNumberValidation = validateBusinessNumber( + business_registration_number.trim() + ); if (!businessNumberValidation.isValid) { res.status(400).json({ success: false, @@ -3253,3 +3273,95 @@ 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/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts new file mode 100644 index 00000000..882d8e62 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -0,0 +1,163 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { + DigitalTwinTemplateService, + DigitalTwinLayoutTemplate, +} from "../services/DigitalTwinTemplateService"; + +export const listMappingTemplates = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const externalDbConnectionId = req.query.externalDbConnectionId + ? Number(req.query.externalDbConnectionId) + : undefined; + const layoutType = + typeof req.query.layoutType === "string" + ? req.query.layoutType + : undefined; + + const result = await DigitalTwinTemplateService.listTemplates( + companyCode, + { + externalDbConnectionId, + layoutType, + }, + ); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: result.message, + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data as DigitalTwinLayoutTemplate[], + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const getMappingTemplateById = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const result = await DigitalTwinTemplateService.getTemplateById( + companyCode, + id, + ); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "매핑 템플릿을 찾을 수 없습니다.", + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const createMappingTemplate = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode || !userId) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const { + name, + description, + externalDbConnectionId, + layoutType, + config, + } = req.body; + + if (!name || !externalDbConnectionId || !config) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const result = await DigitalTwinTemplateService.createTemplate( + companyCode, + userId, + { + name, + description, + externalDbConnectionId, + layoutType, + config, + }, + ); + + if (!result.success || !result.data) { + return res.status(500).json({ + success: false, + message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: result.error, + }); + } + + return res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + + + 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/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index be3a16a3..0ff80988 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,11 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, description, isActive } = req.body; + const { screenName, tableName, description, isActive } = req.body; await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, description, isActive }, + { screenName, tableName, description, isActive }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index 3f469330..cde351dc 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -1,7 +1,11 @@ -import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import { + DatabaseConnector, + ConnectionConfig, + QueryResult, +} from "../interfaces/DatabaseConnector"; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; // @ts-ignore -import * as mysql from 'mysql2/promise'; +import * as mysql from "mysql2/promise"; export class MariaDBConnector implements DatabaseConnector { private connection: mysql.Connection | null = null; @@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector { password: this.config.password, database: this.config.database, connectTimeout: this.config.connectionTimeoutMillis, - ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl, }); } } @@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector { const startTime = Date.now(); try { await this.connect(); - const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const [rows] = await this.connection!.query( + "SELECT VERSION() as version" + ); const version = (rows as any[])[0]?.version || "Unknown"; const responseTime = Date.now() - startTime; await this.disconnect(); @@ -89,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector { ORDER BY TABLE_NAME; `); - const tables: TableInfo[] = []; - for (const row of rows as any[]) { - const columns = await this.getColumns(row.table_name); - tables.push({ - table_name: row.table_name, - description: row.description || null, - columns: columns, - }); - } + // 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회) + const tables: TableInfo[] = (rows as any[]).map((row) => ({ + table_name: row.table_name, + description: row.description || null, + columns: [], + })); + await this.disconnect(); return tables; } catch (error: any) { @@ -111,21 +115,43 @@ export class MariaDBConnector implements DatabaseConnector { console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); await this.connect(); console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); - - const [rows] = await this.connection!.query(` + + const [rows] = await this.connection!.query( + ` SELECT - COLUMN_NAME as column_name, - DATA_TYPE as data_type, - IS_NULLABLE as is_nullable, - COLUMN_DEFAULT as column_default - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? - ORDER BY ORDINAL_POSITION; - `, [tableName]); - + c.COLUMN_NAME AS column_name, + c.DATA_TYPE AS data_type, + c.IS_NULLABLE AS is_nullable, + c.COLUMN_DEFAULT AS column_default, + c.COLUMN_COMMENT AS description, + CASE + WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key + FROM information_schema.COLUMNS c + LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON c.TABLE_SCHEMA = k.TABLE_SCHEMA + AND c.TABLE_NAME = k.TABLE_NAME + AND c.COLUMN_NAME = k.COLUMN_NAME + LEFT JOIN information_schema.TABLE_CONSTRAINTS tc + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + WHERE c.TABLE_SCHEMA = DATABASE() + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION; + `, + [tableName] + ); + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); - console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); - + console.log( + `[MariaDBConnector] 결과 개수:`, + Array.isArray(rows) ? rows.length : "not array" + ); + await this.disconnect(); return rows as any[]; } catch (error: any) { diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts index 52f9ce19..2461cc53 100644 --- a/backend-node/src/database/PostgreSQLConnector.ts +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector { const result = await tempClient.query( ` SELECT - column_name, - data_type, - is_nullable, - column_default, - col_description(c.oid, a.attnum) as column_comment + isc.column_name, + isc.data_type, + isc.is_nullable, + isc.column_default, + col_description(c.oid, a.attnum) as column_comment, + CASE + WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key FROM information_schema.columns isc - LEFT JOIN pg_class c ON c.relname = isc.table_name - LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name - WHERE isc.table_schema = 'public' AND isc.table_name = $1 + LEFT JOIN pg_class c + ON c.relname = isc.table_name + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema) + LEFT JOIN pg_attribute a + ON a.attrelid = c.oid + AND a.attname = isc.column_name + LEFT JOIN information_schema.key_column_usage k + ON k.table_name = isc.table_name + AND k.table_schema = isc.table_schema + AND k.column_name = isc.column_name + LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_name = k.constraint_name + AND tc.table_schema = k.table_schema + AND tc.table_name = k.table_name + AND tc.constraint_type = 'PRIMARY KEY' + WHERE isc.table_schema = 'public' + AND isc.table_name = $1 ORDER BY isc.ordinal_position; `, [tableName] 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/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 7ede970a..f13d65cf 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -7,6 +7,7 @@ import { query, queryOne } from "../../database/db"; import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; import { AuthenticatedRequest } from "../../types/auth"; +import { authenticateToken } from "../../middleware/authMiddleware"; const router = Router(); @@ -217,19 +218,29 @@ router.delete("/:flowId", async (req: Request, res: Response) => { * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute */ -router.post("/:flowId/execute", async (req: Request, res: Response) => { +router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { const { flowId } = req.params; const contextData = req.body; logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { contextDataKeys: Object.keys(contextData), + userId: req.user?.userId, + companyCode: req.user?.companyCode, }); + // 사용자 정보를 contextData에 추가 + const enrichedContextData = { + ...contextData, + userId: req.user?.userId, + userName: req.user?.userName, + companyCode: req.user?.companyCode, + }; + // 플로우 실행 const result = await NodeFlowExecutionService.executeFlow( parseInt(flowId, 10), - contextData + enrichedContextData ); return res.json({ diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 904096f7..467813f0 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -9,6 +9,11 @@ import { updateLayout, deleteLayout, } from "../controllers/digitalTwinLayoutController"; +import { + listMappingTemplates, + getMappingTemplateById, + createMappingTemplate, +} from "../controllers/digitalTwinTemplateController"; // 외부 DB 데이터 조회 import { @@ -27,11 +32,16 @@ const router = express.Router(); router.use(authenticateToken); // ========== 레이아웃 관리 API ========== -router.get("/layouts", getLayouts); // 레이아웃 목록 -router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 -router.post("/layouts", createLayout); // 레이아웃 생성 -router.put("/layouts/:id", updateLayout); // 레이아웃 수정 -router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 +router.get("/layouts", getLayouts); // 레이아웃 목록 +router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 +router.post("/layouts", createLayout); // 레이아웃 생성 +router.put("/layouts/:id", updateLayout); // 레이아웃 수정 +router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 + +// ========== 매핑 템플릿 API ========== +router.get("/mapping-templates", listMappingTemplates); +router.get("/mapping-templates/:id", getMappingTemplateById); +router.post("/mapping-templates", createMappingTemplate); // ========== 외부 DB 데이터 조회 API ========== diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts new file mode 100644 index 00000000..d4818b3a --- /dev/null +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -0,0 +1,172 @@ +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +export interface DigitalTwinLayoutTemplate { + id: string; + company_code: string; + name: string; + description?: string | null; + external_db_connection_id: number; + layout_type: string; + config: any; + created_by: string; + created_at: Date; + updated_by: string; + updated_at: Date; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class DigitalTwinTemplateService { + static async listTemplates( + companyCode: string, + options: { externalDbConnectionId?: number; layoutType?: string } = {}, + ): Promise> { + try { + const params: any[] = [companyCode]; + let paramIndex = 2; + + let query = ` + SELECT * + FROM digital_twin_layout_template + WHERE company_code = $1 + `; + + if (options.layoutType) { + query += ` AND layout_type = $${paramIndex++}`; + params.push(options.layoutType); + } + + if (options.externalDbConnectionId) { + query += ` AND external_db_connection_id = $${paramIndex++}`; + params.push(options.externalDbConnectionId); + } + + query += ` + ORDER BY updated_at DESC, name ASC + `; + + const result = await pool.query(query, params); + + logger.info("디지털 트윈 매핑 템플릿 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return { + success: true, + data: result.rows as DigitalTwinLayoutTemplate[], + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + }; + } + } + + static async getTemplateById( + companyCode: string, + id: string, + ): Promise> { + try { + const query = ` + SELECT * + FROM digital_twin_layout_template + WHERE id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return { + success: false, + message: "매핑 템플릿을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + }; + } + } + + static async createTemplate( + companyCode: string, + userId: string, + payload: { + name: string; + description?: string; + externalDbConnectionId: number; + layoutType?: string; + config: any; + }, + ): Promise> { + try { + const query = ` + INSERT INTO digital_twin_layout_template ( + company_code, + name, + description, + external_db_connection_id, + layout_type, + config, + created_by, + created_at, + updated_by, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING * + `; + + const values = [ + companyCode, + payload.name, + payload.description || null, + payload.externalDbConnectionId, + payload.layoutType || "yard-3d", + JSON.stringify(payload.config), + userId, + ]; + + const result = await pool.query(query, values); + + logger.info("디지털 트윈 매핑 템플릿 생성", { + companyCode, + templateId: result.rows[0].id, + externalDbConnectionId: payload.externalDbConnectionId, + }); + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 생성 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + }; + } + } +} + + + diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 9e06804b..e9485620 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)) { @@ -695,9 +811,39 @@ export class DynamicFormService { const primaryKeyColumn = primaryKeys[0]; console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); - // 동적 UPDATE SQL 생성 (변경된 필드만) + // 🆕 컬럼 타입 조회 (타입 캐스팅용) + const columnTypesQuery = ` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + `; + const columnTypesResult = await query<{ column_name: string; data_type: string }>( + columnTypesQuery, + [tableName] + ); + const columnTypes: Record = {}; + columnTypesResult.forEach((row) => { + columnTypes[row.column_name] = row.data_type; + }); + + console.log("📊 컬럼 타입 정보:", columnTypes); + + // 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함) const setClause = Object.keys(changedFields) - .map((key, index) => `${key} = $${index + 1}`) + .map((key, index) => { + const dataType = columnTypes[key]; + // 숫자 타입인 경우 명시적 캐스팅 + if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') { + return `${key} = $${index + 1}::integer`; + } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') { + return `${key} = $${index + 1}::numeric`; + } else if (dataType === 'boolean') { + return `${key} = $${index + 1}::boolean`; + } else { + // 문자열 타입은 캐스팅 불필요 + return `${key} = $${index + 1}`; + } + }) .join(", "); const values: any[] = Object.values(changedFields); 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/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 546b215a..9cdd85f3 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -938,6 +938,30 @@ export class NodeFlowExecutionService { insertedData[mapping.targetField] = value; }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer"); + const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code"); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + fields.push("writer"); + values.push(userId); + insertedData.writer = userId; + console.log(` 🔧 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + fields.push("company_code"); + values.push(companyCode); + insertedData.company_code = companyCode; + console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } + const sql = ` INSERT INTO ${targetTable} (${fields.join(", ")}) VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) 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/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6c3a3430..9dbe0270 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -321,7 +321,7 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; description?: string; isActive: string }, + updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, userCompanyCode: string ): Promise { // 권한 확인 @@ -343,16 +343,18 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 + // 화면 정보 업데이트 (tableName 포함) await query( `UPDATE screen_definitions SET screen_name = $1, - description = $2, - is_active = $3, - updated_date = $4 - WHERE screen_id = $5`, + table_name = $2, + description = $3, + is_active = $4, + updated_date = $5 + WHERE screen_id = $6`, [ updateData.screenName, + updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), 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/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 45f75f67..00ef509b 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; */ export default function MainPage() { return ( -
+
{/* 메인 컨텐츠 */} {/* Welcome Message */} diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx index 53c6dfb1..f5d7a153 100644 --- a/frontend/app/(main)/page.tsx +++ b/frontend/app/(main)/page.tsx @@ -1,6 +1,6 @@ export default function MainHomePage() { return ( -
+
{/* 대시보드 컨텐츠 */}

WACE 솔루션에 오신 것을 환영합니다!

diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 3b75f262..ce99a685 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ function ScreenViewPage() { const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); - + // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; @@ -178,31 +178,26 @@ function ScreenViewPage() { for (const comp of layout.components) { // type: "component" 또는 type: "widget" 모두 처리 - if (comp.type === 'widget' || comp.type === 'component') { + if (comp.type === "widget" || comp.type === "component") { const widget = comp as any; const fieldName = widget.columnName || widget.id; - + // autoFill 처리 if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; - - if (currentValue === undefined || currentValue === '') { + + if (currentValue === undefined || currentValue === "") { const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; - + // 사용자 정보에서 필터 값 가져오기 const userValue = user?.[userField as keyof typeof user]; - + if (userValue && sourceTable && filterColumn && displayColumn) { try { const { tableTypeApi } = await import("@/lib/api/screen"); - const result = await tableTypeApi.getTableRecord( - sourceTable, - filterColumn, - userValue, - displayColumn - ); - + const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn); + setFormData((prev) => ({ ...prev, [fieldName]: result.value, @@ -233,24 +228,27 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // containerRef는 이미 패딩이 적용된 영역 내부이므로 offsetWidth는 패딩을 제외한 크기입니다 + // 컨테이너의 실제 크기 const containerWidth = containerRef.current.offsetWidth; const containerHeight = containerRef.current.offsetHeight; - // 화면이 잘리지 않도록 가로/세로 중 작은 쪽 기준으로 스케일 조정 - const scaleX = containerWidth / designWidth; - const scaleY = containerHeight / designHeight; - // 전체 화면이 보이도록 작은 쪽 기준으로 스케일 설정 - const newScale = Math.min(scaleX, scaleY); - + // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8) + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; + + // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) + const newScale = availableWidth / designWidth; + console.log("📐 스케일 계산:", { containerWidth, containerHeight, + MARGIN_X, + availableWidth, designWidth, designHeight, - scaleX, - scaleY, finalScale: newScale, + "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, + "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, }); setScale(newScale); @@ -307,503 +305,531 @@ function ScreenViewPage() { return ( -
- {/* 레이아웃 준비 중 로딩 표시 */} - {!layoutReady && ( -
-
- -

화면 준비 중...

-
+
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

- )} +
+ )} - {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} - {layoutReady && layout && layout.components.length > 0 ? ( -
- {/* 최상위 컴포넌트들 렌더링 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - const topLevelComponents = layout.components.filter((component) => !component.parentId); + {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} + {layoutReady && layout && layout.components.length > 0 ? ( +
+ {/* 최상위 컴포넌트들 렌더링 */} + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); - // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 - // 모든 컴포넌트는 원본 위치 그대로 사용 - const widthOffset = 0; + // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 + // 모든 컴포넌트는 원본 위치 그대로 사용 + const widthOffset = 0; - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - // 🔍 전체 버튼 목록 확인 - const allButtons = topLevelComponents.filter((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - return isButton; - }); + const buttonGroups: Record = {}; + const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); - console.log( - "🔍 메뉴에서 발견된 전체 버튼:", - allButtons.map((b) => ({ - id: b.id, - label: b.label, - positionX: b.position.x, - positionY: b.position.y, - width: b.size?.width, - height: b.size?.height, - })), - ); + console.log( + "🔍 메뉴에서 발견된 전체 버튼:", + allButtons.map((b) => ({ + id: b.id, + label: b.label, + positionX: b.position.x, + positionY: b.position.y, + width: b.size?.width, + height: b.size?.height, + })), + ); - topLevelComponents.forEach((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); + topLevelComponents.forEach((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; + if (isButton) { + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as + | FlowVisibilityConfig + | undefined; - // 🔧 임시: 버튼 그룹 기능 완전 비활성화 - // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 - const DISABLE_BUTTON_GROUPS = false; + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = false; - if ( - !DISABLE_BUTTON_GROUPS && - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { + if (!buttonGroups[flowConfig.groupId]) { + buttonGroups[flowConfig.groupId] = []; + } + buttonGroups[flowConfig.groupId].push(component); + processedButtonIds.add(component.id); } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); + // else: 모든 버튼을 개별 렌더링 } - // else: 모든 버튼을 개별 렌더링 - } - }); + }); - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - // TableSearchWidget들을 먼저 찾기 - const tableSearchWidgets = regularComponents.filter( - (c) => (c as any).componentId === "table-search-widget" - ); + // TableSearchWidget들을 먼저 찾기 + const tableSearchWidgets = regularComponents.filter( + (c) => (c as any).componentId === "table-search-widget", + ); - // 디버그: 모든 컴포넌트 타입 확인 - console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({ - id: c.id, - type: c.type, - componentType: (c as any).componentType, - componentId: (c as any).componentId, - }))); - - // 🆕 조건부 컨테이너들을 찾기 - const conditionalContainers = regularComponents.filter( - (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container" - ); - - console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({ - id: c.id, - y: c.position.y, - size: c.size, - }))); + // 디버그: 모든 컴포넌트 타입 확인 + console.log( + "🔍 전체 컴포넌트 타입:", + regularComponents.map((c) => ({ + id: c.id, + type: c.type, + componentType: (c as any).componentType, + componentId: (c as any).componentId, + })), + ); - // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 - const adjustedComponents = regularComponents.map((component) => { - const isTableSearchWidget = (component as any).componentId === "table-search-widget"; - const isConditionalContainer = (component as any).componentId === "conditional-container"; - - if (isTableSearchWidget || isConditionalContainer) { - // 자기 자신은 조정하지 않음 - return component; - } - - let totalHeightAdjustment = 0; - - // TableSearchWidget 높이 조정 - for (const widget of tableSearchWidgets) { - const isBelow = component.position.y > widget.position.y; - const heightDiff = getHeightDiff(screenId, widget.id); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; + // 🆕 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => + (c as any).componentId === "conditional-container" || + (c as any).componentType === "conditional-container", + ); + + console.log( + "🔍 조건부 컨테이너 발견:", + conditionalContainers.map((c) => ({ + id: c.id, + y: c.position.y, + size: c.size, + })), + ); + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 + const adjustedComponents = regularComponents.map((component) => { + const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; + + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 + return component; } - } - - // 🆕 조건부 컨테이너 높이 조정 - for (const container of conditionalContainers) { - const isBelow = component.position.y > container.position.y; - const actualHeight = conditionalContainerHeights[container.id]; - const originalHeight = container.size?.height || 200; - const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0; - - console.log(`🔍 높이 조정 체크:`, { - componentId: component.id, - componentY: component.position.y, - containerY: container.position.y, - isBelow, - actualHeight, - originalHeight, - heightDiff, - containerId: container.id, - containerSize: container.size, - }); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; - console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`); + + let totalHeightAdjustment = 0; + + // TableSearchWidget 높이 조정 + for (const widget of tableSearchWidgets) { + const isBelow = component.position.y > widget.position.y; + const heightDiff = getHeightDiff(screenId, widget.id); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } } - } - - if (totalHeightAdjustment > 0) { - return { - ...component, - position: { - ...component.position, - y: component.position.y + totalHeightAdjustment, - }, - }; - } - - return component; - }); - return ( - <> - {/* 일반 컴포넌트들 */} - {adjustedComponents.map((component) => { - // 화면 관리 해상도를 사용하므로 위치 조정 불필요 - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터:", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - onHeightChange={(componentId, newHeight) => { - setConditionalContainerHeights((prev) => ({ - ...prev, - [componentId]: newHeight, - })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + // 🆕 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? actualHeight - originalHeight : 0; - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터 (자식):", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔍 버튼 그룹 설정 확인 - console.log("🔍 버튼 그룹 설정:", { - groupId, - buttonCount: buttons.length, - buttons: buttons.map((b) => ({ - id: b.id, - label: b.label, - x: b.position.x, - y: b.position.y, - })), - groupConfig: { - layoutBehavior: groupConfig.layoutBehavior, - groupDirection: groupConfig.groupDirection, - groupAlign: groupConfig.groupAlign, - groupGap: groupConfig.groupGap, - }, + console.log(`🔍 높이 조정 체크:`, { + componentId: component.id, + componentY: component.position.y, + containerY: container.position.y, + isBelow, + actualHeight, + originalHeight, + heightDiff, + containerId: container.id, + containerSize: container.size, }); - // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, - // 각 버튼의 상대 위치는 원래 위치를 유지 - const firstButtonPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼 그룹 위치에도 widthOffset 적용 - const adjustedGroupPosition = { - ...firstButtonPosition, - x: firstButtonPosition.x + widthOffset, - }; - - // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + console.log( + `📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`, + ); } + } - return ( -
- 0) { + return { + ...component, + position: { + ...component.position, + y: component.position.y + totalHeightAdjustment, + }, + }; + } + + return component; + }); + + return ( + <> + {/* 일반 컴포넌트들 */} + {adjustedComponents.map((component) => { + // 화면 관리 해상도를 사용하므로 위치 조정 불필요 + return ( + { - // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 - const relativeButton = { - ...button, - position: { - x: button.position.x - firstButtonPosition.x, - y: button.position.y - firstButtonPosition.y, - z: button.position.z || 1, - }, - }; + onClick={() => {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + menuObjid={menuObjid} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터:", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; - return ( -
-
- {}} + onClick={() => {}} + menuObjid={menuObjid} screenId={screenId} tableName={screen?.tableName} userId={user?.userId} userName={userName} companyCode={companyCode} - tableDisplayData={tableDisplayData} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터 (자식):", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); setTableColumnOrder(columnOrder); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); + setTableDisplayData(tableDisplayData || []); }} refreshKey={tableRefreshKey} onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); - setFlowSelectedStepId(null); + setSelectedRowsData([]); // 선택 해제 }} + formData={formData} onFormDataChange={(fieldName, value) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); }} /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} -
- ) : ( - // 빈 화면일 때 -
-
-
- 📄 -
-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-
-
- )} + ); + })} + + ); + })} - {/* 편집 모달 */} - { - setEditModalOpen(false); - setEditModalConfig({}); - }} - screenId={editModalConfig.screenId} - modalSize={editModalConfig.modalSize} - editData={editModalConfig.editData} - onSave={editModalConfig.onSave} - modalTitle={editModalConfig.modalTitle} - modalDescription={editModalConfig.modalDescription} - onDataChange={(changedFormData) => { - console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); - // 변경된 데이터를 메인 폼에 반영 - setFormData((prev) => { - const updatedFormData = { - ...prev, - ...changedFormData, // 변경된 필드들만 업데이트 - }; - console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); - return updatedFormData; - }); - }} - /> + {/* 🆕 플로우 버튼 그룹들 */} + {Object.entries(buttonGroups).map(([groupId, buttons]) => { + if (buttons.length === 0) return null; + + const firstButton = buttons[0]; + const groupConfig = (firstButton as any).webTypeConfig + ?.flowVisibilityConfig as FlowVisibilityConfig; + + // 🔍 버튼 그룹 설정 확인 + console.log("🔍 버튼 그룹 설정:", { + groupId, + buttonCount: buttons.length, + buttons: buttons.map((b) => ({ + id: b.id, + label: b.label, + x: b.position.x, + y: b.position.y, + })), + groupConfig: { + layoutBehavior: groupConfig.layoutBehavior, + groupDirection: groupConfig.groupDirection, + groupAlign: groupConfig.groupAlign, + groupGap: groupConfig.groupGap, + }, + }); + + // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, + // 각 버튼의 상대 위치는 원래 위치를 유지 + const firstButtonPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼 그룹 위치에도 widthOffset 적용 + const adjustedGroupPosition = { + ...firstButtonPosition, + x: firstButtonPosition.x + widthOffset, + }; + + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + let groupWidth = 0; + let groupHeight = 0; + + if (direction === "horizontal") { + groupWidth = buttons.reduce((total, button, index) => { + const buttonWidth = button.size?.width || 100; + const gapWidth = index < buttons.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); + } else { + groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); + groupHeight = buttons.reduce((total, button, index) => { + const buttonHeight = button.size?.height || 40; + const gapHeight = index < buttons.length - 1 ? gap : 0; + return total + buttonHeight + gapHeight; + }, 0); + } + + return ( +
+ { + // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 + const relativeButton = { + ...button, + position: { + x: button.position.x - firstButtonPosition.x, + y: button.position.y - firstButtonPosition.y, + z: button.position.z || 1, + }, + }; + + return ( +
+
+ {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + tableDisplayData={tableDisplayData} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); + setFlowSelectedStepId(null); + }} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} +
+ ) : ( + // 빈 화면일 때 +
+
+
+ 📄 +
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+
+
+ )} + + {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + modalTitle={editModalConfig.modalTitle} + modalDescription={editModalConfig.modalDescription} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + />
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/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2bb85051..090985ba 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget"; import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; -// 야드 관리 3D 위젯 +// 3D 필드 위젯 const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { ssr: false, loading: () => ( @@ -312,6 +312,24 @@ export function CanvasElement({ return; } + // 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용 + // - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함 + const container = elementRef.current; + if (container) { + const rect = container.getBoundingClientRect(); + const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위 + const isOnBorder = + e.clientX <= rect.left + BORDER_HIT_WIDTH || + e.clientX >= rect.right - BORDER_HIT_WIDTH || + e.clientY <= rect.top + BORDER_HIT_WIDTH || + e.clientY >= rect.bottom - BORDER_HIT_WIDTH; + + if (!isOnBorder) { + // 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음 + return; + } + } + // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { onSelect(element.id); @@ -1067,7 +1085,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( - // 야드 관리 3D 위젯 렌더링 + // 3D 필드 위젯 렌더링
리스트 통계 카드 리스크/알림 - 야드 관리 3D + 3D 필드 {/* 커스텀 통계 카드 */} {/* 커스텀 상태 카드 */} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index db608645..10af48e8 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => { chart: "차트", "map-summary-v2": "지도", "risk-alert-v2": "리스크 알림", - "yard-management-3d": "야드 관리 3D", + "yard-management-3d": "3D 필드", weather: "날씨 위젯", exchange: "환율 위젯", calculator: "계산기", @@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
- {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */} + {/* 레이아웃 선택 (3D 필드 위젯 전용) */} {element.subtype === "yard-management-3d" && (
@@ -199,7 +199,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.uncheckedValue || ""} onChange={(e) => updateConfig("uncheckedValue", e.target.value)} placeholder="N" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -232,7 +232,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.groupLabel || ""} onChange={(e) => updateConfig("groupLabel", e.target.value)} placeholder="체크박스 그룹 제목" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -244,19 +244,19 @@ export const CheckboxConfigPanel: React.FC = ({ value={newOptionLabel} onChange={(e) => setNewOptionLabel(e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> setNewOptionValue(e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -277,13 +277,13 @@ export const CheckboxConfigPanel: React.FC = ({ value={option.label} onChange={(e) => updateOption(index, "label", e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateOption(index, "value", e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> = ({ disabled={localConfig.readonly} required={localConfig.required} defaultChecked={localConfig.defaultChecked} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -308,7 +308,7 @@ export const CodeConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="코드를 입력하세요..." - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -330,7 +330,7 @@ export const CodeConfigPanel: React.FC = ({ value={localConfig.defaultValue || ""} onChange={(e) => updateConfig("defaultValue", e.target.value)} placeholder="기본 코드 내용" - className="font-mono text-xs" style={{ fontSize: "12px" }} + className="font-mono text-xs" rows={4} /> diff --git a/frontend/components/screen/config-panels/DateConfigPanel.tsx b/frontend/components/screen/config-panels/DateConfigPanel.tsx index 7fcacc57..cddac6cb 100644 --- a/frontend/components/screen/config-panels/DateConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DateConfigPanel.tsx @@ -75,7 +75,7 @@ export const DateConfigPanel: React.FC = ({ return ( - + 날짜 설정 @@ -95,7 +95,7 @@ export const DateConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="날짜를 선택하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -149,7 +149,7 @@ export const DateConfigPanel: React.FC = ({ type={localConfig.showTime ? "datetime-local" : "date"} value={localConfig.minDate || ""} onChange={(e) => updateConfig("minDate", e.target.value)} - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -211,9 +234,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("apiEndpoint", e.target.value)} + onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)} + onBlur={handleInputBlur} placeholder="/api/entities/user" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -230,9 +254,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("valueField", e.target.value)} + onChange={(e) => updateConfigLocal("valueField", e.target.value)} + onBlur={handleInputBlur} placeholder="id" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -243,9 +268,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("labelField", e.target.value)} + onChange={(e) => updateConfigLocal("labelField", e.target.value)} + onBlur={handleInputBlur} placeholder="name" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -263,13 +289,13 @@ export const EntityConfigPanel: React.FC = ({ value={newFieldName} onChange={(e) => setNewFieldName(e.target.value)} placeholder="필드명" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> setNewFieldLabel(e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateDisplayField(index, "name", e.target.value)} + onBlur={handleFieldBlur} placeholder="필드명" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateDisplayField(index, "label", e.target.value)} + onBlur={handleFieldBlur} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateConfig("placeholder", e.target.value)} + onChange={(e) => updateConfigLocal("placeholder", e.target.value)} + onBlur={handleInputBlur} placeholder="엔티티를 선택하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -375,9 +407,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("emptyMessage", e.target.value)} + onChange={(e) => updateConfigLocal("emptyMessage", e.target.value)} + onBlur={handleInputBlur} placeholder="검색 결과가 없습니다" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -393,7 +426,7 @@ export const EntityConfigPanel: React.FC = ({ onChange={(e) => updateConfig("minSearchLength", parseInt(e.target.value))} min={0} max={10} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -408,7 +441,7 @@ export const EntityConfigPanel: React.FC = ({ onChange={(e) => updateConfig("pageSize", parseInt(e.target.value))} min={5} max={100} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -462,7 +495,7 @@ export const EntityConfigPanel: React.FC = ({ } }} placeholder='{"status": "active", "department": "IT"}' - className="font-mono text-xs" style={{ fontSize: "12px" }} + className="font-mono text-xs" rows={3} />

API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.

diff --git a/frontend/components/screen/config-panels/FileConfigPanel.tsx b/frontend/components/screen/config-panels/FileConfigPanel.tsx index d07f49ff..d00200f1 100644 --- a/frontend/components/screen/config-panels/FileConfigPanel.tsx +++ b/frontend/components/screen/config-panels/FileConfigPanel.tsx @@ -113,7 +113,7 @@ export const FileConfigPanel: React.FC = ({ return ( - + 파일 업로드 설정 @@ -133,7 +133,7 @@ export const FileConfigPanel: React.FC = ({ value={localConfig.uploadText || ""} onChange={(e) => updateConfig("uploadText", e.target.value)} placeholder="파일을 선택하거나 여기에 드래그하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -146,7 +146,7 @@ export const FileConfigPanel: React.FC = ({ value={localConfig.browseText || ""} onChange={(e) => updateConfig("browseText", e.target.value)} placeholder="파일 선택" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -196,7 +196,7 @@ export const FileConfigPanel: React.FC = ({ min={0.1} max={1024} step={0.1} - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> MB @@ -214,7 +214,7 @@ export const FileConfigPanel: React.FC = ({ onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value))} min={1} max={100} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> )} @@ -257,7 +257,7 @@ export const FileConfigPanel: React.FC = ({ value={newFileType} onChange={(e) => setNewFileType(e.target.value)} placeholder=".pdf 또는 pdf" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -278,7 +278,7 @@ export const RadioConfigPanel: React.FC = ({ value={bulkOptions} onChange={(e) => setBulkOptions(e.target.value)} placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu" - className="h-20 text-xs" style={{ fontSize: "12px" }} + className="h-20 text-xs" /> @@ -273,7 +273,7 @@ export const SelectConfigPanel: React.FC = ({ value={bulkOptions} onChange={(e) => setBulkOptions(e.target.value)} placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu" - className="h-20 text-xs" style={{ fontSize: "12px" }} + className="h-20 text-xs" /> + + + {localTabs.length === 0 ? ( +
+

탭이 없습니다

+

+ 탭 추가 버튼을 클릭하여 새 탭을 생성하세요 +

+
+ ) : ( +
+ {localTabs.map((tab, index) => ( +
+
+
+ + 탭 {index + 1} +
+
+ + + +
+
+ +
+ {/* 탭 라벨 */} +
+ + handleLabelChange(tab.id, e.target.value)} + onBlur={handleLabelBlur} + placeholder="탭 이름" + className="h-8 text-xs sm:h-9 sm:text-sm" + /> +
+ + {/* 화면 선택 */} +
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : ( + + handleScreenSelect(tab.id, screenId, screenName) + } + /> + )} + {tab.screenName && ( +

+ 선택된 화면: {tab.screenName} +

+ )} +
+ + {/* 비활성화 */} +
+ + handleDisabledToggle(tab.id, checked)} + /> +
+
+
+ ))} +
+ )} + + + ); +} + +// 화면 선택 Combobox 컴포넌트 +function ScreenSelectCombobox({ + screens, + selectedScreenId, + onSelect, +}: { + screens: ScreenInfo[]; + selectedScreenId?: number; + onSelect: (screenId: number, screenName: string) => void; +}) { + const [open, setOpen] = useState(false); + + const selectedScreen = screens.find((s) => s.screenId === selectedScreenId); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((screen) => ( + { + onSelect(screen.screenId, screen.screenName); + setOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {screen.screenName} + + 코드: {screen.screenCode} | 테이블: {screen.tableName} + +
+
+ ))} +
+
+
+
+
+ ); +} + diff --git a/frontend/components/screen/config-panels/TextConfigPanel.tsx b/frontend/components/screen/config-panels/TextConfigPanel.tsx index ae5ab0b6..0ef7fa2b 100644 --- a/frontend/components/screen/config-panels/TextConfigPanel.tsx +++ b/frontend/components/screen/config-panels/TextConfigPanel.tsx @@ -55,7 +55,7 @@ export const TextConfigPanel: React.FC = ({ return ( - 텍스트 설정 + 텍스트 설정 텍스트 입력 필드의 세부 설정을 관리합니다. @@ -72,7 +72,7 @@ export const TextConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="입력 안내 텍스트" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -88,7 +88,7 @@ export const TextConfigPanel: React.FC = ({ onChange={(e) => updateConfig("minLength", e.target.value ? parseInt(e.target.value) : undefined)} placeholder="0" min="0" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -102,7 +102,7 @@ export const TextConfigPanel: React.FC = ({ onChange={(e) => updateConfig("maxLength", e.target.value ? parseInt(e.target.value) : undefined)} placeholder="100" min="1" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -141,7 +141,7 @@ export const TextConfigPanel: React.FC = ({ value={localConfig.pattern || ""} onChange={(e) => updateConfig("pattern", e.target.value)} placeholder="예: [A-Za-z0-9]+" - className="font-mono text-xs" style={{ fontSize: "12px" }} + className="font-mono text-xs" />

JavaScript 정규식 패턴을 입력하세요.

@@ -219,7 +219,7 @@ export const TextConfigPanel: React.FC = ({ minLength={localConfig.minLength} pattern={localConfig.pattern} autoComplete={localConfig.autoComplete} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> diff --git a/frontend/components/screen/config-panels/TextareaConfigPanel.tsx b/frontend/components/screen/config-panels/TextareaConfigPanel.tsx index f700e61d..b074074c 100644 --- a/frontend/components/screen/config-panels/TextareaConfigPanel.tsx +++ b/frontend/components/screen/config-panels/TextareaConfigPanel.tsx @@ -68,7 +68,7 @@ export const TextareaConfigPanel: React.FC = ({ return ( - + 텍스트영역 설정 @@ -88,7 +88,7 @@ export const TextareaConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="내용을 입력하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -101,7 +101,7 @@ export const TextareaConfigPanel: React.FC = ({ value={localConfig.defaultValue || ""} onChange={(e) => updateConfig("defaultValue", e.target.value)} placeholder="기본 텍스트 내용" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" rows={3} /> {localConfig.showCharCount && ( @@ -151,7 +151,7 @@ export const TextareaConfigPanel: React.FC = ({ placeholder="자동 (CSS로 제어)" min={10} max={200} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />

비워두면 CSS width로 제어됩니다.

@@ -203,7 +203,7 @@ export const TextareaConfigPanel: React.FC = ({ }} placeholder="제한 없음" min={0} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -221,7 +221,7 @@ export const TextareaConfigPanel: React.FC = ({ }} placeholder="제한 없음" min={1} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -333,7 +333,7 @@ export const TextareaConfigPanel: React.FC = ({ resize: localConfig.resizable ? "both" : "none", minHeight: localConfig.autoHeight ? "auto" : undefined, }} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" wrap={localConfig.wrap} /> {localConfig.showCharCount && ( diff --git a/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx b/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx index 606a3071..f40bd9ea 100644 --- a/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx +++ b/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx @@ -94,7 +94,7 @@ export const FlowButtonGroupDialog: React.FC = ({ max={100} value={gap} onChange={(e) => setGap(Number(e.target.value))} - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" /> {gap}px @@ -109,7 +109,7 @@ export const FlowButtonGroupDialog: React.FC = ({ 정렬 방식 = ({ }} placeholder="추가" disabled={!localValues.enableAdd} - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
-
-
@@ -1284,7 +1284,7 @@ const DataTableConfigPanelComponent: React.FC = ({
-
-
-
@@ -1521,7 +1521,7 @@ const DataTableConfigPanelComponent: React.FC = ({ - + 컬럼 설정 @@ -1654,7 +1654,7 @@ const DataTableConfigPanelComponent: React.FC = ({ } }} placeholder="표시명을 입력하세요" - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
@@ -1947,7 +1947,7 @@ const DataTableConfigPanelComponent: React.FC = ({ }); }} placeholder="고정값 입력..." - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
)} @@ -1967,7 +1967,7 @@ const DataTableConfigPanelComponent: React.FC = ({ - + 필터 설정 @@ -1995,7 +1995,7 @@ const DataTableConfigPanelComponent: React.FC = ({ {component.filters.length === 0 ? (
-

필터가 없습니다

+

필터가 없습니다

컬럼을 추가하면 자동으로 필터가 생성됩니다

) : ( @@ -2073,7 +2073,7 @@ const DataTableConfigPanelComponent: React.FC = ({ updateFilter(index, { label: newValue }); }} placeholder="필터 이름 입력..." - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />

@@ -2192,7 +2192,7 @@ const DataTableConfigPanelComponent: React.FC = ({ - + 모달 및 페이징 설정 @@ -2342,7 +2342,7 @@ const DataTableConfigPanelComponent: React.FC = ({ }); }} /> -

@@ -196,7 +195,6 @@ export const DetailSettingsPanel: React.FC = ({ } }} className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} />
@@ -211,7 +209,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -256,7 +253,6 @@ export const DetailSettingsPanel: React.FC = ({ } }} className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > @@ -316,7 +312,6 @@ export const DetailSettingsPanel: React.FC = ({ } }} className="w-20 rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -332,7 +327,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -348,7 +342,6 @@ export const DetailSettingsPanel: React.FC = ({ value={layoutComponent.layoutConfig?.split?.direction || "horizontal"} onChange={(e) => onUpdateProperty(layoutComponent.id, "layoutConfig.split.direction", e.target.value)} className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > @@ -398,7 +391,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -421,7 +413,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -444,7 +435,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -467,7 +457,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -495,7 +484,6 @@ export const DetailSettingsPanel: React.FC = ({ ); }} className="bg-primary text-primary-foreground hover:bg-primary/90 rounded px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > + 컬럼 추가 @@ -519,7 +507,6 @@ export const DetailSettingsPanel: React.FC = ({ ); }} className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((col) => ( @@ -542,7 +529,6 @@ export const DetailSettingsPanel: React.FC = ({ ); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > 삭제 @@ -578,7 +564,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -593,7 +578,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -683,7 +667,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -711,7 +694,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} placeholder="100%" /> @@ -724,7 +706,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} placeholder="auto" /> @@ -1007,7 +988,7 @@ export const DetailSettingsPanel: React.FC = ({

컴포넌트 설정

- + 타입: {componentType} @@ -1057,7 +1038,7 @@ export const DetailSettingsPanel: React.FC = ({

파일 컴포넌트 설정

- + 타입: 파일 업로드 @@ -1146,14 +1127,14 @@ export const DetailSettingsPanel: React.FC = ({ {/* 컴포넌트 정보 */}
- + 컴포넌트: {componentId}
{webType && currentBaseInputType && (
- + 입력 타입: @@ -1163,7 +1144,7 @@ export const DetailSettingsPanel: React.FC = ({ )} {selectedComponent.columnName && (
- + 컬럼: {selectedComponent.columnName} @@ -1375,7 +1356,7 @@ export const DetailSettingsPanel: React.FC = ({

상세 설정

- + 입력 타입: @@ -1390,7 +1371,7 @@ export const DetailSettingsPanel: React.FC = ({
- + @@ -147,8 +147,7 @@ const ResolutionPanel: React.FC = ({ currentResolution, on placeholder="1920" min="1" step="1" - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
@@ -160,16 +159,14 @@ const ResolutionPanel: React.FC = ({ currentResolution, on placeholder="1080" min="1" step="1" - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
diff --git a/frontend/components/screen/panels/RowSettingsPanel.tsx b/frontend/components/screen/panels/RowSettingsPanel.tsx index 1378ffe3..c88eda92 100644 --- a/frontend/components/screen/panels/RowSettingsPanel.tsx +++ b/frontend/components/screen/panels/RowSettingsPanel.tsx @@ -109,7 +109,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat variant={row.gap === preset ? "default" : "outline"} size="sm" onClick={() => onUpdateRow({ gap: preset })} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" > {GAP_PRESETS[preset].label} @@ -130,7 +130,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat variant={row.padding === preset ? "default" : "outline"} size="sm" onClick={() => onUpdateRow({ padding: preset })} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" > {GAP_PRESETS[preset].label} diff --git a/frontend/components/screen/panels/TemplatesPanel.tsx b/frontend/components/screen/panels/TemplatesPanel.tsx index d4d4bae9..d76ef0c9 100644 --- a/frontend/components/screen/panels/TemplatesPanel.tsx +++ b/frontend/components/screen/panels/TemplatesPanel.tsx @@ -528,7 +528,7 @@ export const TemplatesPanel: React.FC = ({ onDragStart }) =
- 템플릿 로딩 실패, 기본 템플릿 사용 중 + 템플릿 로딩 실패, 기본 템플릿 사용 중
- + +
설정 패널 로딩 중...
+
+ }> + +
); }; @@ -682,8 +718,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("label", e.target.value)} placeholder="라벨" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} />
@@ -719,7 +753,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ step={1} placeholder="10" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -733,8 +766,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("placeholder", e.target.value)} placeholder="입력 안내 텍스트" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> )} @@ -748,8 +779,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("title", e.target.value)} placeholder="제목" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> )} @@ -763,8 +792,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("description", e.target.value)} placeholder="설명" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> )} @@ -806,7 +833,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ } }} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} /> @@ -818,8 +844,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={currentPosition.z || 1} onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" /> @@ -837,8 +862,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelText || selectedComponent.label || ""} onChange={(e) => handleUpdate("style.labelText", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -848,8 +872,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelFontSize || "12px"} onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -859,8 +882,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelColor || "#212121"} onChange={(e) => handleUpdate("style.labelColor", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -871,8 +893,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelMarginBottom || "4px"} onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -994,6 +1015,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; @@ -1013,7 +1044,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
handleUpdate("webType", value)}> - + diff --git a/frontend/components/screen/panels/WebTypeConfigPanel.tsx b/frontend/components/screen/panels/WebTypeConfigPanel.tsx index 8c44fb48..da190e91 100644 --- a/frontend/components/screen/panels/WebTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/WebTypeConfigPanel.tsx @@ -109,7 +109,7 @@ export const WebTypeConfigPanel: React.FC = ({ webType, <>
-
-