diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index b53454b9..df975d8f 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2574,11 +2574,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons const companyCode = req.user?.companyCode || "*"; const { searchTerm } = req.query; - let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')"; const params: any[] = []; let paramIndex = 1; - // 회사 코드 필터링 (멀티테넌시) + // 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만 if (companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); @@ -2592,11 +2592,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons paramIndex++; } - // POP 그룹 조회 (계층 구조를 위해 전체 조회) + // POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함) const dataQuery = ` SELECT sg.*, - (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT COUNT(*) FROM screen_group_screens sgs + INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code + WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count, (SELECT json_agg( json_build_object( 'id', sgs.id, @@ -2609,7 +2611,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons ) ORDER BY sgs.display_order ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id - WHERE sgs.group_id = sg.id + INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code + WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code ) as screens FROM screen_groups sg ${whereClause} @@ -2768,6 +2771,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo const existing = await pool.query(checkQuery, checkParams); if (existing.rows.length === 0) { + // 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공 + const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]); + if (anyGroup.rows.length > 0) { + return res.status(403).json({ + success: false, + message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.` + }); + } return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); } @@ -2782,7 +2793,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo [id] ); if (parseInt(childCheck.rows[0].count) > 0) { - return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + return res.status(400).json({ + success: false, + message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.` + }); } // 연결된 화면 확인 @@ -2791,7 +2805,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo [id] ); if (parseInt(screenCheck.rows[0].count) > 0) { - return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + return res.status(400).json({ + success: false, + message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.` + }); } // 삭제 @@ -2806,33 +2823,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo } }; -// POP 루트 그룹 확보 (없으면 자동 생성) +// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포) export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; - // POP 루트 그룹 확인 - const checkQuery = ` - SELECT * FROM screen_groups - WHERE hierarchy_path = 'POP' AND company_code = $1 - `; - const existing = await pool.query(checkQuery, [companyCode]); - - if (existing.rows.length > 0) { - return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + // 최고관리자만 자동 생성 + if (companyCode !== "*") { + const existing = await pool.query( + `SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`, + [companyCode] + ); + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0] }); + } + return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." }); + } + + // 최고관리자(*): 루트 그룹 확인 후 없으면 생성 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = '*' + `; + const existing = await pool.query(checkQuery, []); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0] }); } - // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) const insertQuery = ` INSERT INTO screen_groups ( group_name, group_code, hierarchy_path, company_code, description, display_order, is_active, writer - ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + ) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1) RETURNING * `; - const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + const result = await pool.query(insertQuery, [req.user?.userId || ""]); - logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id }); res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); } catch (error: any) { diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 53ff1b96..afd03461 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -1237,3 +1237,82 @@ export const copyCascadingRelation = async ( }); } }; + +// POP 화면 연결 분석 +export const analyzePopScreenLinks = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + + const result = await screenManagementService.analyzePopScreenLinks( + parseInt(screenId), + companyCode, + ); + + res.json({ success: true, data: result }); + } catch (error: any) { + console.error("POP 화면 연결 분석 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "POP 화면 연결 분석에 실패했습니다.", + }); + } +}; + +// POP 화면 배포 (다른 회사로 복사) +export const deployPopScreens = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screens, targetCompanyCode, groupStructure } = req.body; + const { companyCode, userId } = req.user as any; + + if (!screens || !Array.isArray(screens) || screens.length === 0) { + res.status(400).json({ + success: false, + message: "배포할 화면 목록이 필요합니다.", + }); + return; + } + + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + message: "대상 회사 코드가 필요합니다.", + }); + return; + } + + if (companyCode !== "*") { + res.status(403).json({ + success: false, + message: "최고 관리자만 POP 화면을 배포할 수 있습니다.", + }); + return; + } + + const result = await screenManagementService.deployPopScreens({ + screens, + groupStructure: groupStructure || undefined, + targetCompanyCode, + companyCode, + userId, + }); + + res.json({ + success: true, + data: result, + message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`, + }); + } catch (error: any) { + console.error("POP 화면 배포 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "POP 화면 배포에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 456a74a0..767cb912 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -42,6 +42,8 @@ import { copyCategoryMapping, copyTableTypeColumns, copyCascadingRelation, + analyzePopScreenLinks, + deployPopScreens, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -123,4 +125,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns); // 연쇄관계 설정 복제 router.post("/copy-cascading-relation", copyCascadingRelation); +// POP 화면 배포 (다른 회사로 복사) +router.get("/screens/:screenId/pop-links", analyzePopScreenLinks); +router.post("/deploy-pop-screens", deployPopScreens); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 77c82a91..0cf2da7e 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5425,28 +5425,24 @@ export class ScreenManagementService { async getScreenIdsWithPopLayout( companyCode: string, ): Promise { - console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); - console.log(`회사 코드: ${companyCode}`); - let result: { screen_id: number }[]; if (companyCode === "*") { - // 최고 관리자: 모든 POP 레이아웃 조회 result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop`, [], ); } else { - // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 + // 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용) result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop - WHERE company_code = $1 OR company_code = '*'`, + WHERE company_code = $1`, [companyCode], ); } const screenIds = result.map((r) => r.screen_id); - console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); + logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length }); return screenIds; } @@ -5484,6 +5480,512 @@ export class ScreenManagementService { console.log(`POP 레이아웃 삭제 완료`); return true; } + + // ============================================================ + // POP 화면 배포 (다른 회사로 복사) + // ============================================================ + + /** + * POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석 + */ + async analyzePopScreenLinks( + screenId: number, + companyCode: string, + ): Promise<{ + linkedScreenIds: number[]; + references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }>; + }> { + const layoutResult = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + if (!layoutResult?.layout_data) { + return { linkedScreenIds: [], references: [] }; + } + + const layoutData = layoutResult.layout_data; + const references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }> = []; + + const scanComponents = (components: Record) => { + for (const [compId, comp] of Object.entries(components)) { + const config = (comp as any).config || {}; + + if (config.cart?.cartScreenId) { + const sid = parseInt(config.cart.cartScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "cartScreenId", + targetScreenId: sid, + }); + } + } + + if (config.cartListMode?.sourceScreenId) { + const sid = + typeof config.cartListMode.sourceScreenId === "number" + ? config.cartListMode.sourceScreenId + : parseInt(config.cartListMode.sourceScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "sourceScreenId", + targetScreenId: sid, + }); + } + } + + if (Array.isArray(config.followUpActions)) { + for (const action of config.followUpActions) { + if (action.targetScreenId) { + const sid = parseInt(action.targetScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "targetScreenId", + targetScreenId: sid, + }); + } + } + } + } + + if (config.action?.modalScreenId) { + const sid = parseInt(config.action.modalScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "modalScreenId", + targetScreenId: sid, + }); + } + } + } + }; + + if (layoutData.components) { + scanComponents(layoutData.components); + } + + if (Array.isArray(layoutData.modals)) { + for (const modal of layoutData.modals) { + if (modal.components) { + scanComponents(modal.components); + } + } + } + + const linkedScreenIds = [ + ...new Set(references.map((r) => r.targetScreenId)), + ]; + + return { linkedScreenIds, references }; + } + + /** + * POP 화면 배포 (최고관리자 화면을 특정 회사로 복사) + * - screen_definitions + screen_layouts_pop 복사 + * - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환 + * - numberingRuleId 초기화 + */ + async deployPopScreens(data: { + screens: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + groupStructure?: { + sourceGroupId: number; + groupName: string; + groupCode: string; + children?: Array<{ + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + }>; + screenIds: number[]; + }; + targetCompanyCode: string; + companyCode: string; + userId: string; + }): Promise<{ + deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }>; + createdGroups?: number; + }> { + if (data.companyCode !== "*") { + throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다."); + } + + return await transaction(async (client) => { + const screenIdMap = new Map(); + const deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }> = []; + + // 1단계: screen_definitions 복사 + for (const screen of data.screens) { + const sourceResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screen.sourceScreenId], + ); + + if (sourceResult.rows.length === 0) { + throw new Error( + `원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`, + ); + } + + const sourceScreen = sourceResult.rows[0]; + + const existingResult = await client.query( + `SELECT screen_id FROM screen_definitions + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + LIMIT 1`, + [screen.screenCode, data.targetCompanyCode], + ); + + if (existingResult.rows.length > 0) { + throw new Error( + `화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`, + ); + } + + const newScreenResult = await client.query( + `INSERT INTO screen_definitions ( + screen_code, screen_name, description, company_code, table_name, + is_active, created_by, created_date, updated_by, updated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING *`, + [ + screen.screenCode, + screen.screenName, + sourceScreen.description, + data.targetCompanyCode, + sourceScreen.table_name, + "Y", + data.userId, + ], + ); + + const newScreen = newScreenResult.rows[0]; + screenIdMap.set(screen.sourceScreenId, newScreen.screen_id); + + deployedScreens.push({ + sourceScreenId: screen.sourceScreenId, + newScreenId: newScreen.screen_id, + screenName: screen.screenName, + screenCode: screen.screenCode, + }); + + logger.info("POP 화면 배포 - screen_definitions 생성", { + sourceScreenId: screen.sourceScreenId, + newScreenId: newScreen.screen_id, + targetCompanyCode: data.targetCompanyCode, + }); + } + + // 2단계: screen_layouts_pop 복사 + 참조 치환 + for (const screen of data.screens) { + const newScreenId = screenIdMap.get(screen.sourceScreenId); + if (!newScreenId) continue; + + // 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback) + let layoutResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screen.sourceScreenId], + ); + + let layoutData = layoutResult.rows[0]?.layout_data; + if (!layoutData) { + const fallbackResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 LIMIT 1`, + [screen.sourceScreenId], + ); + layoutData = fallbackResult.rows[0]?.layout_data; + } + + if (!layoutData) { + logger.warn("POP 레이아웃 없음, 건너뜀", { + sourceScreenId: screen.sourceScreenId, + }); + continue; + } + + const updatedLayoutData = this.updatePopLayoutScreenReferences( + JSON.parse(JSON.stringify(layoutData)), + screenIdMap, + ); + + await client.query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), $4, $4) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, + [ + newScreenId, + data.targetCompanyCode, + JSON.stringify(updatedLayoutData), + data.userId, + ], + ); + + logger.info("POP 레이아웃 복사 완료", { + sourceScreenId: screen.sourceScreenId, + newScreenId, + componentCount: Object.keys(updatedLayoutData.components || {}) + .length, + }); + } + + // 3단계: 그룹 구조 복사 (groupStructure가 있는 경우) + let createdGroups = 0; + if (data.groupStructure) { + const gs = data.groupStructure; + + // 대상 회사의 POP 루트 그룹 찾기/생성 + let popRootResult = await client.query( + `SELECT id FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`, + [data.targetCompanyCode], + ); + + let popRootId: number; + if (popRootResult.rows.length > 0) { + popRootId = popRootResult.rows[0].id; + } else { + const createRootResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order) + VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`, + [data.targetCompanyCode, data.userId], + ); + popRootId = createRootResult.rows[0].id; + } + + // 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가) + const mainGroupCode = gs.groupCode + "_COPY"; + const dupCheck = await client.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [mainGroupCode, data.targetCompanyCode], + ); + + let mainGroupId: number; + if (dupCheck.rows.length > 0) { + mainGroupId = dupCheck.rows[0].id; + } else { + const mainGroupResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`, + [ + gs.groupName, + mainGroupCode, + `POP/${mainGroupCode}`, + data.targetCompanyCode, + popRootId, + data.userId, + ], + ); + mainGroupId = mainGroupResult.rows[0].id; + createdGroups++; + } + + // 메인 그룹에 화면 연결 + for (const oldScreenId of gs.screenIds) { + const newScreenId = screenIdMap.get(oldScreenId); + if (!newScreenId) continue; + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code) + VALUES ($1, $2, 'main', 0, 'N', $3) + ON CONFLICT DO NOTHING`, + [mainGroupId, newScreenId, data.targetCompanyCode], + ); + } + + // 하위 그룹 생성 + 화면 연결 + if (gs.children) { + for (let i = 0; i < gs.children.length; i++) { + const child = gs.children[i]; + const childGroupCode = child.groupCode + "_COPY"; + + const childDupCheck = await client.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [childGroupCode, data.targetCompanyCode], + ); + + let childGroupId: number; + if (childDupCheck.rows.length > 0) { + childGroupId = childDupCheck.rows[0].id; + } else { + const childResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`, + [ + child.groupName, + childGroupCode, + `POP/${mainGroupCode}/${childGroupCode}`, + data.targetCompanyCode, + mainGroupId, + data.userId, + i, + ], + ); + childGroupId = childResult.rows[0].id; + createdGroups++; + } + + for (const oldScreenId of child.screenIds) { + const newScreenId = screenIdMap.get(oldScreenId); + if (!newScreenId) continue; + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code) + VALUES ($1, $2, 'main', 0, 'N', $3) + ON CONFLICT DO NOTHING`, + [childGroupId, newScreenId, data.targetCompanyCode], + ); + } + } + } + + logger.info("POP 그룹 구조 복사 완료", { + targetCompanyCode: data.targetCompanyCode, + createdGroups, + mainGroupName: gs.groupName, + }); + } + + return { deployedScreens, createdGroups }; + }); + } + + /** + * POP layout_data 내 screen_id 참조 치환 + * componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요 + */ + private updatePopLayoutScreenReferences( + layoutData: any, + screenIdMap: Map, + ): any { + if (!layoutData?.components) return layoutData; + + const updateComponents = ( + components: Record, + ): Record => { + const updated: Record = {}; + + for (const [compId, comp] of Object.entries(components)) { + const updatedComp = JSON.parse(JSON.stringify(comp)); + const config = updatedComp.config || {}; + + // cart.cartScreenId (string) + if (config.cart?.cartScreenId) { + const oldId = parseInt(config.cart.cartScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + config.cart.cartScreenId = String(newId); + logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`); + } + } + + // cartListMode.sourceScreenId (number) + if (config.cartListMode?.sourceScreenId) { + const oldId = + typeof config.cartListMode.sourceScreenId === "number" + ? config.cartListMode.sourceScreenId + : parseInt(config.cartListMode.sourceScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + config.cartListMode.sourceScreenId = newId; + logger.info( + `POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`, + ); + } + } + + // followUpActions[].targetScreenId (string) + if (Array.isArray(config.followUpActions)) { + for (const action of config.followUpActions) { + if (action.targetScreenId) { + const oldId = parseInt(action.targetScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + action.targetScreenId = String(newId); + logger.info( + `POP 참조 치환: targetScreenId ${oldId} -> ${newId}`, + ); + } + } + } + } + + // action.modalScreenId (숫자형이면 화면 참조로 간주) + if (config.action?.modalScreenId) { + const oldId = parseInt(config.action.modalScreenId); + if (!isNaN(oldId)) { + const newId = screenIdMap.get(oldId); + if (newId) { + config.action.modalScreenId = String(newId); + logger.info( + `POP 참조 치환: modalScreenId ${oldId} -> ${newId}`, + ); + } + } + } + + // numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요) + if (config.numberingRuleId) { + logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`); + config.numberingRuleId = ""; + } + if (config.autoGenMappings) { + for (const mapping of Object.values(config.autoGenMappings) as any[]) { + if (mapping?.numberingRuleId) { + logger.info( + `POP 채번규칙 초기화: ${mapping.numberingRuleId}`, + ); + mapping.numberingRuleId = ""; + } + } + } + + updatedComp.config = config; + updated[compId] = updatedComp; + } + + return updated; + }; + + layoutData.components = updateComponents(layoutData.components); + + if (Array.isArray(layoutData.modals)) { + for (const modal of layoutData.modals) { + if (modal.components) { + modal.components = updateComponents(modal.components); + } + } + } + + return layoutData; + } } // 서비스 인스턴스 export diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx index d9e289ca..d8c10cf6 100644 --- a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -13,6 +13,7 @@ import { Settings, LayoutGrid, GitBranch, + Upload, } from "lucide-react"; import { PopDesigner } from "@/components/pop/designer"; import { ScrollToTop } from "@/components/common/ScrollToTop"; @@ -27,6 +28,7 @@ import { PopScreenPreview, PopScreenFlowView, PopScreenSettingModal, + PopDeployModal, } from "@/components/pop/management"; import { PopScreenGroup } from "@/lib/api/popScreenGroup"; @@ -62,6 +64,10 @@ export default function PopScreenManagementPage() { // UI 상태 const [isCreateOpen, setIsCreateOpen] = useState(false); const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [isDeployModalOpen, setIsDeployModalOpen] = useState(false); + const [deployGroupScreens, setDeployGroupScreens] = useState([]); + const [deployGroupName, setDeployGroupName] = useState(""); + const [deployGroupInfo, setDeployGroupInfo] = useState(undefined); const [devicePreview, setDevicePreview] = useState("tablet"); const [rightPanelView, setRightPanelView] = useState("preview"); @@ -235,6 +241,21 @@ export default function PopScreenManagementPage() { + {selectedScreen && ( + + )} - - - onScreenDesign(screen)}> - - 설계 - - - openMoveModal(screen, null)}> - - 카테고리로 이동 - - - handleDeleteScreen(screen)} - > - - 화면 삭제 - - - - - ))} + + {screen.screenName} + #{screen.screenId} + + + + + + + onScreenDesign(screen)}> + + 설계 + + onScreenSettings?.(screen)}> + + 설정 (이름 변경) + + onScreenCopy?.(screen)}> + + 복사 + + + openMoveModal(screen, null)}> + + 카테고리로 이동 + + + handleDeleteScreen(screen)} + > + + 화면 삭제 + + + + + ))} + + )); + })()} )} diff --git a/frontend/components/pop/management/PopDeployModal.tsx b/frontend/components/pop/management/PopDeployModal.tsx new file mode 100644 index 00000000..46f8cc68 --- /dev/null +++ b/frontend/components/pop/management/PopDeployModal.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { GroupCopyInfo } from "./PopCategoryTree"; +import { getCompanyList } from "@/lib/api/company"; +import { ScreenDefinition } from "@/types/screen"; +import { Company } from "@/types/company"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface LinkedScreenInfo { + screenId: number; + screenName: string; + screenCode: string; + references: Array<{ + componentId: string; + referenceType: string; + }>; + deploy: boolean; + newScreenName: string; + newScreenCode: string; +} + +interface ScreenEntry { + screenId: number; + screenName: string; + newScreenName: string; + newScreenCode: string; + included: boolean; +} + +interface PopDeployModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + screen: ScreenDefinition | null; + groupScreens?: ScreenDefinition[]; + groupName?: string; + groupInfo?: GroupCopyInfo; + allScreens: ScreenDefinition[]; + onDeployed?: () => void; +} + +export function PopDeployModal({ + open, + onOpenChange, + screen, + groupScreens, + groupName, + groupInfo, + allScreens, + onDeployed, +}: PopDeployModalProps) { + const isGroupMode = !!(groupScreens && groupScreens.length > 0); + + const [companies, setCompanies] = useState([]); + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + + // 단일 화면 모드 + const [screenName, setScreenName] = useState(""); + const [screenCode, setScreenCode] = useState(""); + const [linkedScreens, setLinkedScreens] = useState([]); + + // 그룹 모드 + const [groupEntries, setGroupEntries] = useState([]); + + const [analyzing, setAnalyzing] = useState(false); + const [deploying, setDeploying] = useState(false); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + getCompanyList({ status: "active" }) + .then((list) => { + setCompanies(list.filter((c) => c.company_code !== "*")); + }) + .catch(console.error); + } + }, [open]); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (!open) return; + + setTargetCompanyCode(""); + setLinkedScreens([]); + + if (isGroupMode && groupScreens) { + setGroupEntries( + groupScreens.map((s) => ({ + screenId: s.screenId, + screenName: s.screenName, + newScreenName: s.screenName, + newScreenCode: "", + included: true, + })), + ); + setScreenName(""); + setScreenCode(""); + } else if (screen) { + setScreenName(screen.screenName); + setScreenCode(""); + setGroupEntries([]); + analyzeLinks(screen.screenId); + } + }, [open, screen, groupScreens, isGroupMode]); + + // 회사 선택 시 화면 코드 자동 생성 + useEffect(() => { + if (!targetCompanyCode) return; + + if (isGroupMode) { + const count = groupEntries.filter((e) => e.included).length; + if (count > 0) { + screenApi + .generateMultipleScreenCodes(targetCompanyCode, count) + .then((codes) => { + let codeIdx = 0; + setGroupEntries((prev) => + prev.map((e) => + e.included + ? { ...e, newScreenCode: codes[codeIdx++] || "" } + : e, + ), + ); + }) + .catch(console.error); + } + } else { + const count = 1 + linkedScreens.filter((ls) => ls.deploy).length; + screenApi + .generateMultipleScreenCodes(targetCompanyCode, count) + .then((codes) => { + setScreenCode(codes[0] || ""); + setLinkedScreens((prev) => + prev.map((ls, idx) => ({ + ...ls, + newScreenCode: codes[idx + 1] || "", + })), + ); + }) + .catch(console.error); + } + }, [targetCompanyCode]); + + const analyzeLinks = async (screenId: number) => { + setAnalyzing(true); + try { + const result = await screenApi.analyzePopScreenLinks(screenId); + const linked: LinkedScreenInfo[] = result.linkedScreenIds.map( + (linkedId) => { + const linkedScreen = allScreens.find( + (s) => s.screenId === linkedId, + ); + const refs = result.references.filter( + (r) => r.targetScreenId === linkedId, + ); + return { + screenId: linkedId, + screenName: linkedScreen?.screenName || `화면 ${linkedId}`, + screenCode: linkedScreen?.screenCode || "", + references: refs.map((r) => ({ + componentId: r.componentId, + referenceType: r.referenceType, + })), + deploy: true, + newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`, + newScreenCode: "", + }; + }, + ); + setLinkedScreens(linked); + } catch (error) { + console.error("연결 분석 실패:", error); + } finally { + setAnalyzing(false); + } + }; + + const handleDeploy = async () => { + if (!targetCompanyCode) return; + + setDeploying(true); + try { + let screensToSend: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + + if (isGroupMode) { + screensToSend = groupEntries + .filter((e) => e.included && e.newScreenCode) + .map((e) => ({ + sourceScreenId: e.screenId, + screenName: e.newScreenName, + screenCode: e.newScreenCode, + })); + } else { + if (!screen || !screenName || !screenCode) return; + screensToSend = [ + { + sourceScreenId: screen.screenId, + screenName, + screenCode, + }, + ...linkedScreens + .filter((ls) => ls.deploy) + .map((ls) => ({ + sourceScreenId: ls.screenId, + screenName: ls.newScreenName, + screenCode: ls.newScreenCode, + })), + ]; + } + + if (screensToSend.length === 0) { + toast.error("복사할 화면이 없습니다."); + return; + } + + const deployPayload: Parameters[0] = { + screens: screensToSend, + targetCompanyCode, + }; + + if (isGroupMode && groupInfo) { + deployPayload.groupStructure = groupInfo; + } + + const result = await screenApi.deployPopScreens(deployPayload); + + const groupMsg = result.createdGroups + ? ` (카테고리 ${result.createdGroups}개 생성)` + : ""; + toast.success( + `POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`, + ); + onOpenChange(false); + onDeployed?.(); + } catch (error: any) { + toast.error(error?.response?.data?.message || "복사에 실패했습니다."); + } finally { + setDeploying(false); + } + }; + + const totalCount = isGroupMode + ? groupEntries.filter((e) => e.included).length + : 1 + linkedScreens.filter((ls) => ls.deploy).length; + + const canDeploy = isGroupMode + ? !deploying && targetCompanyCode && groupEntries.some((e) => e.included) + : !deploying && targetCompanyCode && screenName && screenCode; + + return ( + + + + + POP 화면 복사 + + + {isGroupMode + ? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.` + : screen + ? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.` + : "화면을 선택해주세요."} + + + +
+ {/* 대상 회사 선택 */} +
+ + +
+ + {/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */} + {isGroupMode ? ( +
+ +
+ {groupInfo ? ( +
+ {/* 메인 카테고리 */} +
+ + {groupInfo.groupName} + + + 새 카테고리 생성 + +
+ {/* 메인 카테고리의 직접 화면 */} + {groupEntries + .filter((e) => groupInfo.screenIds.includes(e.screenId)) + .map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} + {/* 하위 카테고리들 */} + {groupInfo.children?.map((child) => ( +
+
+ + {child.groupName} +
+ {groupEntries + .filter((e) => + child.screenIds.includes(e.screenId), + ) + .map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} +
+ ))} +
+ ) : ( +
+ {groupEntries.map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} +
+ )} +
+

+ 카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로 + 복사됩니다. +

+
+ ) : ( + <> + {/* ===== 단일 모드: 화면명 + 코드 ===== */} +
+ + setScreenName(e.target.value)} + placeholder="화면 이름" + /> +
+ +
+ + +
+ + {/* 연결 화면 감지 */} + {analyzing ? ( +
+ + 연결된 화면을 분석 중입니다... +
+ ) : linkedScreens.length > 0 ? ( +
+
+ + 연결된 POP 화면 {linkedScreens.length}개 감지됨 +
+
+ {linkedScreens.map((ls) => ( +
+
+
{ls.screenName}
+
+ ID: {ls.screenId} |{" "} + {ls.references + .map((r) => r.referenceType) + .join(", ")} +
+
+
+ { + setLinkedScreens((prev) => + prev.map((item) => + item.screenId === ls.screenId + ? { ...item, deploy: !!checked } + : item, + ), + ); + }} + /> + 함께 복사 +
+
+ ))} +
+

+ 함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동 + 치환됩니다. +

+
+ ) : ( + !analyzing && ( +
+ 연결된 POP 화면이 없습니다. 이 화면만 복사됩니다. +
+ ) + )} + + )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenSettingModal.tsx b/frontend/components/pop/management/PopScreenSettingModal.tsx index 7dd7a11e..3c260423 100644 --- a/frontend/components/pop/management/PopScreenSettingModal.tsx +++ b/frontend/components/pop/management/PopScreenSettingModal.tsx @@ -165,19 +165,26 @@ export function PopScreenSettingModal({ try { setSaving(true); - // 화면 기본 정보 업데이트 const screenUpdate: Partial = { screenName, description: screenDescription, }; + // screen_definitions 테이블에 화면명/설명 업데이트 + if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) { + await screenApi.updateScreenInfo(screen.screenId, { + screenName, + description: screenDescription, + isActive: "Y", + }); + } + // 레이아웃에 하위 화면 정보 저장 const currentLayout = await screenApi.getLayoutPop(screen.screenId); const updatedLayout = { ...currentLayout, version: "pop-1.0", subScreens: subScreens, - // flow 배열 자동 생성 (메인 → 각 서브) flow: subScreens.map((sub) => ({ from: sub.triggerFrom || "main", to: sub.id, @@ -201,11 +208,11 @@ export function PopScreenSettingModal({ return ( - - + + POP 화면 설정 - {screen.screenName} ({screen.screenCode}) + {screen.screenName} [{screen.screenCode}] @@ -214,57 +221,57 @@ export function PopScreenSettingModal({ onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0" > - + - - 개요 + + 기본 정보 - + 하위 화면 {subScreens.length > 0 && ( - + {subScreens.length} )} - + 화면 흐름 - {/* 개요 탭 */} - + {/* 기본 정보 탭 */} + {loading ? ( -
+
) : ( -
-
+
+
setScreenName(e.target.value)} - placeholder="화면 이름" + placeholder="화면 이름을 입력하세요" className="h-8 text-xs sm:h-10 sm:text-sm" />
-
+
@@ -282,7 +289,7 @@ export function PopScreenSettingModal({
-
+
@@ -290,13 +297,13 @@ export function PopScreenSettingModal({ id="description" value={screenDescription} onChange={(e) => setScreenDescription(e.target.value)} - placeholder="화면에 대한 설명" + placeholder="화면에 대한 설명을 입력하세요" rows={3} className="text-xs sm:text-sm resize-none" />
-
+
@@ -307,7 +314,7 @@ export function PopScreenSettingModal({ placeholder="lucide 아이콘 이름 (예: Package)" className="h-8 text-xs sm:h-10 sm:text-sm" /> -

+

lucide-react 아이콘 이름을 입력하세요.

@@ -316,19 +323,19 @@ export function PopScreenSettingModal({ {/* 하위 화면 탭 */} - -
+ +
-

- 이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다. +

+ 모달, 드로어 등 하위 화면을 관리합니다.

-
- + {subScreens.length === 0 ? (
@@ -339,12 +346,12 @@ export function PopScreenSettingModal({
) : (
- {subScreens.map((subScreen, index) => ( + {subScreens.map((subScreen) => (
- +
@@ -362,7 +369,7 @@ export function PopScreenSettingModal({ updateSubScreen(subScreen.id, "type", v) } > - + @@ -374,7 +381,7 @@ export function PopScreenSettingModal({
- + 트리거: