From 61a7f585b4c55048e1e37c93e05a1c50bb7666c4 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 14 Jan 2026 10:20:27 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 45 +++ .../src/controllers/multilangController.ts | 77 +++++ backend-node/src/routes/multilangRoutes.ts | 6 + backend-node/src/services/multilangService.ts | 287 ++++++++++++++++++ .../components/screen/DesignerToolbar.tsx | 19 ++ frontend/components/screen/ScreenDesigner.tsx | 100 ++++++ .../components/screen/toolbar/SlimToolbar.tsx | 18 +- frontend/lib/api/multilang.ts | 40 +++ 8 files changed, 591 insertions(+), 1 deletion(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 231a7cdc..78ea320b 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1165,6 +1165,33 @@ export async function saveMenu( logger.info("메뉴 저장 성공", { savedMenu }); + // 다국어 메뉴 카테고리 자동 생성 + try { + const { MultiLangService } = await import("../services/multilangService"); + const multilangService = new MultiLangService(); + + // 회사명 조회 + 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); + + // 메뉴 경로 조회 및 카테고리 생성 + const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString()); + await multilangService.ensureMenuCategory(companyCode, companyName, menuPath); + + logger.info("메뉴 다국어 카테고리 생성 완료", { + menuObjId: savedMenu.objid.toString(), + menuPath, + }); + } catch (categoryError) { + logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", { + menuObjId: savedMenu.objid.toString(), + error: categoryError, + }); + } + const response: ApiResponse = { success: true, message: "메뉴가 성공적으로 저장되었습니다.", @@ -2649,6 +2676,24 @@ export const createCompany = async ( }); } + // 다국어 카테고리 자동 생성 + try { + const { MultiLangService } = await import("../services/multilangService"); + const multilangService = new MultiLangService(); + await multilangService.ensureCompanyCategory( + createdCompany.company_code, + createdCompany.company_name + ); + logger.info("회사 다국어 카테고리 생성 완료", { + companyCode: createdCompany.company_code, + }); + } catch (categoryError) { + logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", { + companyCode: createdCompany.company_code, + error: categoryError, + }); + } + logger.info("회사 등록 성공", { companyCode: createdCompany.company_code, companyName: createdCompany.company_name, diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index fe211d0c..e88ec912 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1098,3 +1098,80 @@ export const getBatchTranslations = async ( }); } }; + +/** + * POST /api/multilang/screen-labels + * 화면 라벨 다국어 키 자동 생성 API + */ +export const generateScreenLabelKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId, menuObjId, labels } = req.body; + + logger.info("화면 라벨 다국어 키 생성 요청", { + screenId, + menuObjId, + labelCount: labels?.length, + user: req.user, + }); + + // 필수 파라미터 검증 + if (!screenId) { + res.status(400).json({ + success: false, + message: "screenId는 필수입니다.", + error: { code: "MISSING_SCREEN_ID" }, + }); + return; + } + + if (!labels || !Array.isArray(labels) || labels.length === 0) { + res.status(400).json({ + success: false, + message: "labels 배열이 필요합니다.", + error: { code: "MISSING_LABELS" }, + }); + return; + } + + // 사용자 회사 정보 + const companyCode = 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); + + const multiLangService = new MultiLangService(); + const results = await multiLangService.generateScreenLabelKeys({ + screenId: Number(screenId), + companyCode, + companyName, + menuObjId, + labels, + }); + + const response: ApiResponse = { + success: true, + message: `${results.length}개의 다국어 키가 생성되었습니다.`, + data: results, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("화면 라벨 다국어 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.", + error: { + code: "SCREEN_LABEL_KEY_GENERATION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; diff --git a/backend-node/src/routes/multilangRoutes.ts b/backend-node/src/routes/multilangRoutes.ts index 484b7535..00ec04d6 100644 --- a/backend-node/src/routes/multilangRoutes.ts +++ b/backend-node/src/routes/multilangRoutes.ts @@ -32,6 +32,9 @@ import { previewKey, createOverrideKey, getOverrideKeys, + + // 화면 라벨 다국어 API + generateScreenLabelKeys, } from "../controllers/multilangController"; const router = express.Router(); @@ -73,4 +76,7 @@ router.post("/keys/preview", previewKey); // 키 미리보기 router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성 router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회 +// 화면 라벨 다국어 자동 생성 API +router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성 + export default router; diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index f725f9fa..2e624fef 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -1293,4 +1293,291 @@ export class MultiLangService { ); } } + + // ===================================================== + // 회사/메뉴 기반 카테고리 자동 생성 메서드 + // ===================================================== + + /** + * 화면(screen) 루트 카테고리 확인 또는 생성 + */ + async ensureScreenRootCategory(): Promise { + try { + // 기존 screen 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_code = 'screen' AND parent_id IS NULL`, + [] + ); + + if (existing) { + return existing.category_id; + } + + // 없으면 생성 + 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 ('screen', '화면', NULL, 1, 'screen', '화면 디자이너에서 자동 생성된 다국어 키', 100, 'Y', NOW()) + RETURNING category_id`, + [] + ); + + logger.info("화면 루트 카테고리 생성", { categoryId: result?.category_id }); + return result!.category_id; + } catch (error) { + logger.error("화면 루트 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 회사 카테고리 확인 또는 생성 + */ + async ensureCompanyCategory(companyCode: string, companyName: string): Promise { + try { + const screenRootId = await this.ensureScreenRootCategory(); + + // 기존 회사 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_code = $1 AND parent_id = $2`, + [companyCode, screenRootId] + ); + + if (existing) { + return existing.category_id; + } + + // 회사 카테고리 생성 + const displayName = companyCode === "*" ? "공통" : companyName; + const keyPrefix = companyCode === "*" ? "common" : companyCode.toLowerCase(); + + 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, 2, $4, $5, $6, 'Y', NOW()) + RETURNING category_id`, + [ + companyCode, + displayName, + screenRootId, + keyPrefix, + `${displayName} 회사의 화면 다국어`, + companyCode === "*" ? 0 : 10, + ] + ); + + logger.info("회사 카테고리 생성", { companyCode, categoryId: result?.category_id }); + return result!.category_id; + } catch (error) { + logger.error("회사 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 메뉴 카테고리 확인 또는 생성 (메뉴 경로 전체) + */ + async ensureMenuCategory( + companyCode: string, + companyName: string, + menuPath: string[] // ["영업관리", "수주관리"] + ): Promise { + try { + if (menuPath.length === 0) { + return await this.ensureCompanyCategory(companyCode, companyName); + } + + let parentId = await this.ensureCompanyCategory(companyCode, companyName); + let currentLevel = 3; + + for (const menuName of menuPath) { + // 현재 메뉴 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_name = $1 AND parent_id = $2`, + [menuName, parentId] + ); + + if (existing) { + parentId = existing.category_id; + } else { + // 메뉴 카테고리 생성 + const menuCode = `${companyCode}_${menuName}`.replace(/\s+/g, "_"); + const keyPrefix = menuName.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`, + [menuCode, menuName, parentId, currentLevel, keyPrefix, `${menuName} 메뉴의 다국어`] + ); + + logger.info("메뉴 카테고리 생성", { menuName, categoryId: result?.category_id }); + parentId = result!.category_id; + } + + currentLevel++; + } + + return parentId; + } catch (error) { + logger.error("메뉴 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 메뉴 경로 조회 (menu_info에서 부모 메뉴까지) + */ + async getMenuPath(menuObjId: string): Promise { + try { + const menus = await query<{ menu_name_kor: string; level: number }>( + `WITH RECURSIVE menu_path AS ( + SELECT objid, parent_obj_id, menu_name_kor, 1 as level + FROM menu_info + WHERE objid = $1 + UNION ALL + SELECT m.objid, m.parent_obj_id, m.menu_name_kor, mp.level + 1 + FROM menu_info m + INNER JOIN menu_path mp ON m.objid = mp.parent_obj_id + WHERE m.parent_obj_id IS NOT NULL AND m.parent_obj_id != 0 + ) + SELECT menu_name_kor, level FROM menu_path + WHERE menu_name_kor IS NOT NULL + ORDER BY level DESC`, + [menuObjId] + ); + + return menus.map((m) => m.menu_name_kor); + } catch (error) { + logger.error("메뉴 경로 조회 실패:", error); + return []; + } + } + + /** + * 화면 라벨 다국어 키 자동 생성 + */ + async generateScreenLabelKeys(params: { + screenId: number; + companyCode: string; + companyName: string; + menuObjId?: string; + labels: Array<{ componentId: string; label: string; type?: string }>; + }): Promise> { + try { + logger.info("화면 라벨 다국어 키 자동 생성 시작", { + screenId: params.screenId, + companyCode: params.companyCode, + labelCount: params.labels.length, + }); + + // 메뉴 경로 조회 + const menuPath = params.menuObjId + ? await this.getMenuPath(params.menuObjId) + : []; + + // 메뉴 카테고리 확보 + const categoryId = await this.ensureMenuCategory( + params.companyCode, + params.companyName, + menuPath + ); + + // 카테고리 경로 조회 (키 생성용) + const categoryPath = await this.getCategoryPath(categoryId); + const keyPrefixParts = categoryPath.map((c) => c.keyPrefix); + + const results: Array<{ componentId: string; keyId: number; langKey: string }> = []; + + for (const labelInfo of params.labels) { + // 라벨을 키 형태로 변환 (한글 → 스네이크케이스) + const keyMeaning = this.labelToKeyMeaning(labelInfo.label); + const langKey = [...keyPrefixParts, keyMeaning].join("."); + + // 기존 키 확인 + const existingKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE lang_key = $1 AND company_code = $2`, + [langKey, params.companyCode] + ); + + let keyId: number; + + if (existingKey) { + keyId = existingKey.key_id; + logger.info("기존 키 사용", { langKey, keyId }); + } else { + // 새 키 생성 + const keyResult = await queryOne<{ key_id: number }>( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, description, is_active, category_id, key_meaning, created_date, created_by) + VALUES ($1, $2, $3, 'Y', $4, $5, NOW(), 'system') + RETURNING key_id`, + [ + params.companyCode, + langKey, + `화면 ${params.screenId}의 ${labelInfo.type || "라벨"}: ${labelInfo.label}`, + categoryId, + keyMeaning, + ] + ); + + keyId = keyResult!.key_id; + + // 한국어 텍스트 저장 (원문) + await query( + `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_date, created_by) + VALUES ($1, 'KR', $2, 'Y', NOW(), 'system') + ON CONFLICT (key_id, lang_code) DO UPDATE SET lang_text = $2, updated_date = NOW()`, + [keyId, labelInfo.label] + ); + + logger.info("새 키 생성", { langKey, keyId }); + } + + results.push({ + componentId: labelInfo.componentId, + keyId, + langKey, + }); + } + + logger.info("화면 라벨 다국어 키 생성 완료", { + screenId: params.screenId, + generatedCount: results.length, + }); + + return results; + } catch (error) { + logger.error("화면 라벨 다국어 키 생성 실패:", error); + throw new Error( + `화면 라벨 다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 라벨을 키 의미로 변환 (한글 → 스네이크케이스 또는 영문 유지) + */ + private labelToKeyMeaning(label: string): string { + // 이미 영문 스네이크케이스면 그대로 사용 + if (/^[a-z][a-z0-9_]*$/.test(label)) { + return label; + } + + // 영문 일반이면 스네이크케이스로 변환 + if (/^[A-Za-z][A-Za-z0-9 ]*$/.test(label)) { + return label.toLowerCase().replace(/\s+/g, "_"); + } + + // 한글이면 간단한 변환 (특수문자 제거, 공백을 _로) + return label + .replace(/[^\w가-힣\s]/g, "") + .replace(/\s+/g, "_") + .toLowerCase(); + } } diff --git a/frontend/components/screen/DesignerToolbar.tsx b/frontend/components/screen/DesignerToolbar.tsx index a50e8f09..366a1418 100644 --- a/frontend/components/screen/DesignerToolbar.tsx +++ b/frontend/components/screen/DesignerToolbar.tsx @@ -17,6 +17,7 @@ import { Layout, Monitor, Square, + Languages, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -34,6 +35,8 @@ interface DesignerToolbarProps { isSaving?: boolean; showZoneBorders?: boolean; onToggleZoneBorders?: () => void; + onGenerateMultilang?: () => void; + isGeneratingMultilang?: boolean; } export const DesignerToolbar: React.FC = ({ @@ -50,6 +53,8 @@ export const DesignerToolbar: React.FC = ({ isSaving = false, showZoneBorders = true, onToggleZoneBorders, + onGenerateMultilang, + isGeneratingMultilang = false, }) => { return (
@@ -226,6 +231,20 @@ export const DesignerToolbar: React.FC = ({
+ {onGenerateMultilang && ( + + )} + )} + {onGenerateMultilang && ( + + )}