diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index c77b50d7..f14fc3b5 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1137,16 +1137,22 @@ export const generateScreenLabelKeys = async ( return; } - // 사용자 회사 정보 - const companyCode = req.user?.companyCode || "*"; + // 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준) + const { queryOne } = await import("../database/db"); + const screenInfo = await queryOne<{ company_code: string }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1`, + [screenId] + ); + const companyCode = screenInfo?.company_code || req.user?.companyCode || "*"; // 회사명 조회 - const { queryOne } = await import("../database/db"); const companyInfo = await queryOne<{ company_name: string }>( `SELECT company_name FROM company_mng WHERE company_code = $1`, [companyCode] ); const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode); + + logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName }); const multiLangService = new MultiLangService(); const results = await multiLangService.generateScreenLabelKeys({ diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 52464ed4..45a2da62 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,10 +1,14 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { MultiLangService } from "../services/multilangService"; // pool 인스턴스 가져오기 const pool = getPool(); +// 다국어 서비스 인스턴스 +const multiLangService = new MultiLangService(); + // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ @@ -191,6 +195,47 @@ export const createScreenGroup = async (req: Request, res: Response) => { // 업데이트된 데이터 반환 const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); + // 다국어 카테고리 자동 생성 (그룹 경로 기반) + try { + // 그룹 경로 조회 (상위 그룹 → 현재 그룹) + const groupPathResult = await pool.query( + `WITH RECURSIVE group_path AS ( + SELECT id, parent_group_id, group_name, group_level, 1 as depth + FROM screen_groups + WHERE id = $1 + UNION ALL + SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 + FROM screen_groups g + INNER JOIN group_path gp ON g.id = gp.parent_group_id + WHERE g.parent_group_id IS NOT NULL + ) + SELECT group_name FROM group_path + ORDER BY depth DESC`, + [newGroupId] + ); + + const groupPath = groupPathResult.rows.map((r: any) => r.group_name); + + // 회사 이름 조회 + let companyName = "공통"; + if (finalCompanyCode !== "*") { + const companyResult = await pool.query( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [finalCompanyCode] + ); + if (companyResult.rows.length > 0) { + companyName = companyResult.rows[0].company_name; + } + } + + // 다국어 카테고리 생성 + await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath); + logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode }); + } catch (multilangError: any) { + // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리 + logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message); + } + logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index 2944644e..06daf725 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -1475,14 +1475,115 @@ export class MultiLangService { } } + /** + * 화면 그룹 경로 조회 (screen_groups에서 계층 구조 조회) + * @param screenId 화면 ID + * @returns 그룹 경로 배열 (최상위 → 현재 그룹 순서) + */ + async getScreenGroupPath(screenId: number): Promise { + try { + // 화면이 속한 그룹 조회 + const screenGroup = await queryOne<{ group_id: number }>( + `SELECT group_id FROM screen_group_screens WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (!screenGroup) { + logger.info("화면이 그룹에 속하지 않음", { screenId }); + return []; + } + + // 그룹의 계층 구조 경로 조회 (최상위 → 현재 그룹 순서) + const groups = await query<{ group_name: string; group_level: number }>( + `WITH RECURSIVE group_path AS ( + SELECT id, parent_group_id, group_name, group_level, 1 as depth + FROM screen_groups + WHERE id = $1 + UNION ALL + SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 + FROM screen_groups g + INNER JOIN group_path gp ON g.id = gp.parent_group_id + WHERE g.parent_group_id IS NOT NULL + ) + SELECT group_name, group_level FROM group_path + ORDER BY depth DESC`, + [screenGroup.group_id] + ); + + return groups.map((g) => g.group_name); + } catch (error) { + logger.error("화면 그룹 경로 조회 실패:", error); + return []; + } + } + + /** + * 화면 그룹 기반 카테고리 확인 또는 생성 + * @param companyCode 회사 코드 + * @param companyName 회사 이름 + * @param groupPath 그룹 경로 (상위 → 하위 순서) + * @returns 최종 카테고리 ID + */ + async ensureScreenGroupCategory( + companyCode: string, + companyName: string, + groupPath: string[] + ): Promise { + try { + if (groupPath.length === 0) { + // 그룹이 없으면 회사 카테고리만 반환 + return await this.ensureCompanyCategory(companyCode, companyName); + } + + let parentId = await this.ensureCompanyCategory(companyCode, companyName); + let currentLevel = 3; // SCREEN(1) > Company(2) > Group(3) + + for (const groupName of groupPath) { + // 현재 그룹 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_name = $1 AND parent_id = $2`, + [groupName, parentId] + ); + + if (existing) { + parentId = existing.category_id; + } else { + // 그룹 카테고리 생성 + const groupCode = `${companyCode}_GROUP_${groupName}`.replace(/\s+/g, "_"); + const keyPrefix = groupName.toLowerCase().replace(/\s+/g, "_"); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW()) + RETURNING category_id`, + [groupCode, groupName, parentId, currentLevel, keyPrefix, `${groupName} 화면 그룹의 다국어`] + ); + + logger.info("화면 그룹 카테고리 생성", { groupName, categoryId: result?.category_id }); + parentId = result!.category_id; + } + + currentLevel++; + } + + return parentId; + } catch (error) { + logger.error("화면 그룹 카테고리 생성 실패:", error); + throw error; + } + } + /** * 화면 라벨 다국어 키 자동 생성 + * 화면 그룹 기반으로 카테고리 생성 (기존 메뉴 기반에서 변경) */ async generateScreenLabelKeys(params: { screenId: number; companyCode: string; companyName: string; - menuObjId?: string; + menuObjId?: string; // 하위 호환성 유지 (미사용, 화면 그룹 기반으로 변경) labels: Array<{ componentId: string; label: string; type?: string }>; }): Promise> { try { @@ -1492,16 +1593,15 @@ export class MultiLangService { labelCount: params.labels.length, }); - // 메뉴 경로 조회 - const menuPath = params.menuObjId - ? await this.getMenuPath(params.menuObjId) - : []; + // 화면 그룹 경로 조회 (화면이 속한 그룹의 계층 구조) + const groupPath = await this.getScreenGroupPath(params.screenId); + logger.info("화면 그룹 경로 조회 완료", { screenId: params.screenId, groupPath }); - // 메뉴 카테고리 확보 - const categoryId = await this.ensureMenuCategory( + // 화면 그룹 기반 카테고리 확보 + const categoryId = await this.ensureScreenGroupCategory( params.companyCode, params.companyName, - menuPath + groupPath ); // 카테고리 경로 조회 (키 생성용) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index c4cfac05..4fbaa6d8 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1518,12 +1518,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const updatedComponents = applyMultilangMappings(layout.components, response.data); // 레이아웃 업데이트 - setLayout((prev) => ({ - ...prev, + const updatedLayout = { + ...layout, components: updatedComponents, - })); + screenResolution: screenResolution, + }; + + setLayout(updatedLayout); - toast.success(`${response.data.length}개의 다국어 키가 생성되고 컴포넌트에 매핑되었습니다.`); + // 자동 저장 (매핑 정보가 손실되지 않도록) + try { + await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); + toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`); + } catch (saveError) { + console.error("다국어 매핑 저장 실패:", saveError); + toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`); + } } else { toast.error(response.error?.details || "다국어 키 생성에 실패했습니다."); } @@ -1533,7 +1543,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } finally { setIsGeneratingMultilang(false); } - }, [selectedScreen, layout.components, menuObjid]); + }, [selectedScreen, layout, screenResolution, menuObjid]); // 템플릿 드래그 처리 const handleTemplateDrop = useCallback( diff --git a/frontend/components/screen/modals/MultilangSettingsModal.tsx b/frontend/components/screen/modals/MultilangSettingsModal.tsx index 1f9acc22..ee237ff6 100644 --- a/frontend/components/screen/modals/MultilangSettingsModal.tsx +++ b/frontend/components/screen/modals/MultilangSettingsModal.tsx @@ -468,9 +468,17 @@ export const MultilangSettingsModal: React.FC = ({ addLabel(comp.id, anyComp.title, "title", parentType, parentLabel); } - // 3. 버튼 텍스트 + // 3. 버튼 텍스트 (componentId에 _button 접미사 추가하여 라벨과 구분) if (config?.text && typeof config.text === "string") { - addLabel(comp.id, config.text, "button", parentType, parentLabel); + addLabel( + `${comp.id}_button`, + config.text, + "button", + parentType, + parentLabel, + config.langKeyId, + config.langKey + ); } // 4. placeholder diff --git a/frontend/lib/utils/multilangLabelExtractor.ts b/frontend/lib/utils/multilangLabelExtractor.ts index 0e5a34c5..242b613a 100644 --- a/frontend/lib/utils/multilangLabelExtractor.ts +++ b/frontend/lib/utils/multilangLabelExtractor.ts @@ -105,9 +105,17 @@ export function extractMultilangLabels( addLabel(comp.id, anyComp.title, "title", parentType, parentLabel); } - // 3. 버튼 텍스트 + // 3. 버튼 텍스트 (componentId에 _button 접미사 추가하여 라벨과 구분) if (config?.text && typeof config.text === "string") { - addLabel(comp.id, config.text, "button", parentType, parentLabel); + addLabel( + `${comp.id}_button`, + config.text, + "button", + parentType, + parentLabel, + config.langKeyId, + config.langKey + ); } // 4. placeholder @@ -326,15 +334,16 @@ export function applyMultilangMappings( if (labelMapping) { updated.langKeyId = labelMapping.keyId; updated.langKey = labelMapping.langKey; - - // 버튼 컴포넌트의 경우 componentConfig에도 매핑 - if (config?.text) { - updated.componentConfig = { - ...updated.componentConfig, - langKeyId: labelMapping.keyId, - langKey: labelMapping.langKey, - }; - } + } + + // 버튼 텍스트 매핑 (componentId_button 형식으로 조회) + const buttonMapping = mappingMap.get(`${comp.id}_button`); + if (buttonMapping && config?.text) { + updated.componentConfig = { + ...updated.componentConfig, + langKeyId: buttonMapping.keyId, + langKey: buttonMapping.langKey, + }; } // 컬럼 매핑