From 8667cb4780d318545f8454697e33b83584a712ce Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 25 Aug 2025 11:07:39 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=82=AC=EA=B4=80=EB=A6=AC,=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package-lock.json | 4 +- backend-node/package.json | 56 +- .../src/controllers/adminController.ts | 555 +++++++++++++++++- .../src/controllers/multilangController.ts | 313 +++++++++- backend-node/src/routes/adminRoutes.ts | 12 + backend-node/src/routes/multilangRoutes.ts | 12 +- backend-node/src/types/common.ts | 1 + frontend/app/(main)/admin/company/page.tsx | 8 + frontend/app/(main)/admin/layout.tsx | 68 ++- .../components/admin/CompanyDeleteDialog.tsx | 2 + .../components/admin/CompanyManagement.tsx | 5 +- frontend/components/admin/MenuFormModal.tsx | 262 +++++---- frontend/components/admin/MenuManagement.tsx | 35 +- frontend/hooks/useCompanyManagement.ts | 5 + frontend/hooks/useMultiLang.ts | 137 +++-- frontend/lib/api/client.ts | 2 +- frontend/lib/api/company.ts | 91 ++- frontend/lib/api/menu.ts | 19 +- frontend/lib/utils/multilang.ts | 468 +++++++-------- 19 files changed, 1471 insertions(+), 584 deletions(-) create mode 100644 frontend/app/(main)/admin/company/page.tsx diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 13ab4b82..ea50778c 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -21,7 +21,7 @@ "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.7", - "pg": "^8.11.3", + "pg": "^8.16.3", "prisma": "^5.7.1", "redis": "^4.6.10", "winston": "^3.11.0" @@ -39,7 +39,7 @@ "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", - "@types/pg": "^8.10.9", + "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.14.0", diff --git a/backend-node/package.json b/backend-node/package.json index 18d83e5c..ed6a1622 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -27,49 +27,49 @@ "author": "", "license": "ISC", "dependencies": { - "express": "^4.18.2", - "prisma": "^5.7.1", "@prisma/client": "^5.7.1", - "pg": "^8.11.3", - "jsonwebtoken": "^9.0.2", "bcryptjs": "^2.4.3", - "helmet": "^7.1.0", + "compression": "^1.7.4", "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "joi": "^17.11.0", + "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.7", - "winston": "^3.11.0", - "joi": "^17.11.0", + "pg": "^8.16.3", + "prisma": "^5.7.1", "redis": "^4.6.10", - "compression": "^1.7.4", - "express-rate-limit": "^7.1.5", - "dotenv": "^16.3.1" + "winston": "^3.11.0" }, "devDependencies": { - "typescript": "^5.3.3", - "@types/node": "^20.10.5", - "@types/express": "^4.17.21", - "@types/pg": "^8.10.9", - "@types/jsonwebtoken": "^9.0.5", "@types/bcryptjs": "^2.4.6", - "@types/cors": "^2.8.17", - "@types/multer": "^1.4.11", - "@types/nodemailer": "^6.4.14", - "@types/morgan": "^1.9.9", "@types/compression": "^1.7.5", - "@types/sanitize-html": "^2.9.5", - "@types/node-cron": "^3.0.11", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", - "jest": "^29.7.0", "@types/jest": "^29.5.11", - "supertest": "^6.3.3", + "@types/jsonwebtoken": "^9.0.5", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.11", + "@types/node": "^20.10.5", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.14", + "@types/pg": "^8.15.5", + "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", - "ts-jest": "^29.1.1", - "nodemon": "^3.0.2", - "ts-node": "^10.9.2", - "eslint": "^8.55.0", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", - "prettier": "^3.1.0" + "eslint": "^8.55.0", + "jest": "^29.7.0", + "nodemon": "^3.0.2", + "prettier": "^3.1.0", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" }, "engines": { "node": ">=20.10.0", diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 3275968d..2f3fd6d0 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1,9 +1,9 @@ -import { Response } from "express"; -import { Request } from "express"; -import { AuthenticatedRequest } from "../types/auth"; -import { AdminService } from "../services/adminService"; +import { Request, Response } from "express"; import { logger } from "../utils/logger"; -import { ApiResponse } from "../types/auth"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { Client } from "pg"; +import { AdminService } from "../services/adminService"; /** * 관리자 메뉴 목록 조회 @@ -250,15 +250,47 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { export const getUserLocale = async ( req: AuthenticatedRequest, res: Response -) => { +): Promise => { try { logger.info("사용자 로케일 조회 요청", { query: req.query, user: req.user, }); - // 임시 더미 데이터 반환 (실제로는 데이터베이스에서 조회) - const userLocale = "ko"; // 기본값 + if (!req.user?.userId) { + res.status(400).json({ + success: false, + message: "사용자 정보가 없습니다.", + }); + return; + } + + // 데이터베이스에서 사용자 로케일 조회 + const prisma = (await import("../config/database")).default; + + const userInfo = await prisma.user_info.findFirst({ + where: { + user_id: req.user.userId, + }, + select: { + locale: true, + }, + }); + + let userLocale = "en"; // 기본값 + + if (userInfo?.locale) { + userLocale = userInfo.locale; + logger.info("데이터베이스에서 사용자 로케일 조회 성공", { + userId: req.user.userId, + locale: userLocale, + }); + } else { + logger.info("사용자 로케일이 설정되지 않음, 기본값 사용", { + userId: req.user.userId, + defaultLocale: userLocale, + }); + } const response = { success: true, @@ -268,6 +300,8 @@ export const getUserLocale = async ( logger.info("사용자 로케일 조회 성공", { userLocale, + userId: req.user.userId, + fromDatabase: !!userInfo?.locale, }); res.status(200).json(response); @@ -282,7 +316,76 @@ export const getUserLocale = async ( }; /** - * GET /api/admin/companies + * 사용자 로케일 설정 + */ +export const setUserLocale = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + logger.info("사용자 로케일 설정 요청", { + body: req.body, + user: req.user, + }); + + if (!req.user?.userId) { + res.status(400).json({ + success: false, + message: "사용자 정보가 없습니다.", + }); + return; + } + + const { locale } = req.body; + + if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)", + }); + return; + } + + // 데이터베이스에 사용자 로케일 저장 + const prisma = (await import("../config/database")).default; + + await prisma.user_info.update({ + where: { + user_id: req.user.userId, + }, + data: { + locale: locale, + }, + }); + + logger.info("사용자 로케일을 데이터베이스에 저장 완료", { + locale, + userId: req.user.userId, + }); + + const response = { + success: true, + data: locale, + message: "사용자 로케일 설정 성공", + }; + + logger.info("사용자 로케일 설정 성공", { + locale, + userId: req.user.userId, + }); + + res.status(200).json(response); + } catch (error) { + logger.error("사용자 로케일 설정 실패", { error }); + res.status(500).json({ + success: false, + message: "사용자 로케일 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** * 회사 목록 조회 API */ export const getCompanyList = async ( @@ -300,17 +403,27 @@ export const getCompanyList = async ( { company_code: "ILSHIN", company_name: "일신제강", + status: "active", + writer: "admin", + regdate: new Date().toISOString(), }, { company_code: "HUTECH", company_name: "후테크", + status: "active", + writer: "admin", + regdate: new Date().toISOString(), }, { company_code: "DAIN", company_name: "다인", + status: "active", + writer: "admin", + regdate: new Date().toISOString(), }, ]; + // 프론트엔드에서 기대하는 응답 형식으로 변환 const response = { success: true, data: dummyCompanies, @@ -319,6 +432,7 @@ export const getCompanyList = async ( logger.info("회사 목록 조회 성공", { totalCount: dummyCompanies.length, + response: response, }); res.status(200).json(response); @@ -390,7 +504,10 @@ export async function getLangKeyList( res: Response ): Promise { try { - logger.info("다국어 키 목록 조회 요청"); + logger.info("다국어 키 목록 조회 요청", { + query: req.query, + user: req.user, + }); // 더미 데이터 반환 const langKeys = [ @@ -401,6 +518,10 @@ export async function getLangKeyList( langKey: "user.management.title", description: "사용자 관리 페이지 제목", isActive: "Y", + createdDate: new Date().toISOString(), + createdBy: "admin", + updatedDate: new Date().toISOString(), + updatedBy: "admin", }, { keyId: 2, @@ -409,6 +530,10 @@ export async function getLangKeyList( langKey: "menu.management.title", description: "메뉴 관리 페이지 제목", isActive: "Y", + createdDate: new Date().toISOString(), + createdBy: "admin", + updatedDate: new Date().toISOString(), + updatedBy: "admin", }, { keyId: 3, @@ -417,15 +542,25 @@ export async function getLangKeyList( langKey: "dashboard.title", description: "대시보드 페이지 제목", isActive: "Y", + createdDate: new Date().toISOString(), + createdBy: "admin", + updatedDate: new Date().toISOString(), + updatedBy: "admin", }, ]; + // 프론트엔드에서 기대하는 응답 형식으로 변환 const response: ApiResponse = { success: true, message: "다국어 키 목록 조회 성공", data: langKeys, }; + logger.info("다국어 키 목록 조회 성공", { + totalCount: langKeys.length, + response: response, + }); + res.status(200).json(response); } catch (error) { logger.error("다국어 키 목록 조회 실패:", error); @@ -724,3 +859,403 @@ export async function toggleLanguageStatus( }); } } + +/** + * 메뉴 저장 (추가/수정) + */ +export async function saveMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const menuData = req.body; + logger.info("메뉴 저장 요청", { menuData, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + // 실제 데이터베이스에 저장 + const query = ` + INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_url, menu_desc, writer, regdate, status, + system_name, company_code, lang_key, lang_key_desc + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) RETURNING * + `; + + const values = [ + Date.now(), // objid + menuData.menuType || null, // menu_type + menuData.parentObjId || null, // parent_obj_id + menuData.menuNameKor, // menu_name_kor + menuData.menuNameEng || null, // menu_name_eng + menuData.seq || null, // seq + menuData.menuUrl || null, // menu_url + menuData.menuDesc || null, // menu_desc + req.user?.userId || "admin", // writer + new Date(), // regdate + menuData.status || "active", // status + menuData.systemName || "PLM", // system_name + menuData.companyCode || "*", // company_code + menuData.langKey || null, // lang_key + menuData.langKeyDesc || null, // lang_key_desc + ]; + + const result = await client.query(query, values); + const savedMenu = result.rows[0]; + + await client.end(); + + logger.info("메뉴 저장 성공", { savedMenu }); + + const response: ApiResponse = { + success: true, + message: "메뉴가 성공적으로 저장되었습니다.", + data: { + objid: savedMenu.objid, + menuNameKor: savedMenu.menu_name_kor, + menuNameEng: savedMenu.menu_name_eng, + menuUrl: savedMenu.menu_url, + menuDesc: savedMenu.menu_desc, + status: savedMenu.status, + writer: savedMenu.writer, + regdate: savedMenu.regdate, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 저장 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 메뉴 수정 + */ +export async function updateMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuId } = req.params; + const menuData = req.body; + logger.info(`메뉴 수정 요청: menuId = ${menuId}`, { + menuData, + user: req.user, + }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + // 실제 데이터베이스에서 메뉴 수정 + const query = ` + UPDATE menu_info + SET + menu_type = $1, + parent_obj_id = $2, + menu_name_kor = $3, + menu_name_eng = $4, + seq = $5, + menu_url = $6, + menu_desc = $7, + status = $8, + system_name = $9, + company_code = $10, + lang_key = $11, + lang_key_desc = $12 + WHERE objid = $13 + RETURNING * + `; + + const values = [ + menuData.menuType ? BigInt(menuData.menuType) : null, // menu_type + menuData.parentObjId ? BigInt(menuData.parentObjId) : null, // parent_obj_id + menuData.menuNameKor, // menu_name_kor + menuData.menuNameEng || null, // menu_name_eng + menuData.seq ? BigInt(menuData.seq) : null, // seq + menuData.menuUrl || null, // menu_url + menuData.menuDesc || null, // menu_desc + menuData.status || "active", // status + menuData.systemName || "PLM", // system_name + menuData.companyCode || "*", // company_code + menuData.langKey || null, // lang_key + menuData.langKeyDesc || null, // lang_key_desc + BigInt(menuId), // objid (WHERE 조건) + ]; + + const result = await client.query(query, values); + + if (result.rowCount === 0) { + await client.end(); + res.status(404).json({ + success: false, + message: "수정할 메뉴를 찾을 수 없습니다.", + }); + return; + } + + const updatedMenu = result.rows[0]; + await client.end(); + + logger.info("메뉴 수정 성공", { updatedMenu }); + + const response: ApiResponse = { + success: true, + message: "메뉴가 성공적으로 수정되었습니다.", + data: { + objid: updatedMenu.objid.toString(), + menuNameKor: updatedMenu.menu_name_kor, + menuNameEng: updatedMenu.menu_name_eng, + menuUrl: updatedMenu.menu_url, + menuDesc: updatedMenu.menu_desc, + status: updatedMenu.status, + writer: updatedMenu.writer, + regdate: updatedMenu.regdate, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 수정 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 메뉴 삭제 + */ +export async function deleteMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuId } = req.params; + logger.info(`메뉴 삭제 요청: menuId = ${menuId}`, { user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + // 실제 데이터베이스에서 메뉴 삭제 + const query = ` + DELETE FROM menu_info + WHERE objid = $1 + RETURNING * + `; + + const result = await client.query(query, [BigInt(menuId)]); + + if (result.rowCount === 0) { + await client.end(); + res.status(404).json({ + success: false, + message: "삭제할 메뉴를 찾을 수 없습니다.", + }); + return; + } + + const deletedMenu = result.rows[0]; + await client.end(); + + logger.info("메뉴 삭제 성공", { deletedMenu }); + + const response: ApiResponse = { + success: true, + message: "메뉴가 성공적으로 삭제되었습니다.", + data: { + objid: deletedMenu.objid.toString(), + menuNameKor: deletedMenu.menu_name_kor, + menuNameEng: deletedMenu.menu_name_eng, + menuUrl: deletedMenu.menu_url, + menuDesc: deletedMenu.menu_desc, + status: deletedMenu.status, + writer: deletedMenu.writer, + regdate: deletedMenu.regdate, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 메뉴 일괄 삭제 + */ +export async function deleteMenusBatch( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const menuIds = req.body as string[]; + logger.info("메뉴 일괄 삭제 요청", { menuIds, user: req.user }); + + if (!Array.isArray(menuIds) || menuIds.length === 0) { + res.status(400).json({ + success: false, + message: "삭제할 메뉴 ID 목록이 필요합니다.", + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + let deletedCount = 0; + let failedCount = 0; + const deletedMenus: any[] = []; + const failedMenuIds: string[] = []; + + // 각 메뉴 ID에 대해 삭제 시도 + for (const menuId of menuIds) { + try { + const query = ` + DELETE FROM menu_info + WHERE objid = $1 + RETURNING * + `; + + const result = await client.query(query, [BigInt(menuId)]); + + if (result.rowCount && result.rowCount > 0) { + deletedCount++; + deletedMenus.push(result.rows[0]); + } else { + failedCount++; + failedMenuIds.push(menuId); + } + } catch (error) { + logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error); + failedCount++; + failedMenuIds.push(menuId); + } + } + + await client.end(); + + logger.info("메뉴 일괄 삭제 완료", { + total: menuIds.length, + deletedCount, + failedCount, + deletedMenus, + failedMenuIds, + }); + + const response: ApiResponse = { + success: true, + message: `메뉴 일괄 삭제 완료: ${deletedCount}개 삭제, ${failedCount}개 실패`, + data: { + deletedCount, + failedCount, + total: menuIds.length, + deletedMenus, + failedMenuIds, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 일괄 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 일괄 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 회사 목록 조회 (실제 데이터베이스에서) + */ +export async function getCompanyListFromDB( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("회사 목록 조회 요청 (DB)", { user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + // company_mng 테이블에서 회사 목록 조회 + const query = ` + SELECT + company_code, + company_name, + writer, + regdate, + status + FROM company_mng + ORDER BY regdate DESC + `; + + const result = await client.query(query); + const companies = result.rows; + + await client.end(); + + logger.info("회사 목록 조회 성공 (DB)", { count: companies.length }); + + const response: ApiResponse = { + success: true, + message: "회사 목록 조회 성공", + data: companies, + total: companies.length, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("회사 목록 조회 실패 (DB):", error); + res.status(500).json({ + success: false, + message: "회사 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 31e10b11..65600672 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1,17 +1,202 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; +import prisma from "../config/database"; + +// 메모리 캐시 (개발 환경용, 운영에서는 Redis 사용 권장) +const translationCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5분 + +interface CacheEntry { + data: any; + timestamp: number; +} + +/** + * GET /api/multilang/batch + * 다국어 텍스트 배치 조회 API - 여러 키를 한번에 조회 + */ +export const getBatchTranslations = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, menuCode, userLang } = req.query; + const { langKeys } = req.body; // 배열로 여러 키 전달 + + logger.info("다국어 텍스트 배치 조회 요청", { + companyCode, + menuCode, + userLang, + keyCount: langKeys?.length || 0, + user: req.user, + }); + + if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { + res.status(400).json({ + success: false, + message: "langKeys 배열이 필요합니다.", + }); + return; + } + + // 캐시 키 생성 + const cacheKey = `${companyCode}_${userLang}_${langKeys.sort().join("_")}`; + + // 캐시 확인 + const cached = translationCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + logger.info("캐시된 번역 데이터 사용", { + cacheKey, + keyCount: langKeys.length, + }); + res.status(200).json({ + success: true, + data: cached.data, + message: "캐시된 다국어 텍스트 조회 성공", + fromCache: true, + }); + return; + } + + // 1. 모든 키에 대한 마스터 정보를 한번에 조회 + logger.info("다국어 키 마스터 배치 조회 시작", { + keyCount: langKeys.length, + }); + + const langKeyMasters = await prisma.$queryRaw` + SELECT key_id, lang_key, company_code + FROM multi_lang_key_master + WHERE lang_key = ANY(${langKeys}::varchar[]) + AND (company_code = ${companyCode}::varchar OR company_code = '*') + ORDER BY + CASE WHEN company_code = ${companyCode}::varchar THEN 1 ELSE 2 END, + lang_key, + company_code + `; + + logger.info("다국어 키 마스터 배치 조회 결과", { + requestedKeys: langKeys.length, + foundKeys: langKeyMasters.length, + }); + + if (langKeyMasters.length === 0) { + // 마스터 데이터가 없으면 기본값 반환 + const defaultTranslations = getDefaultTranslations( + langKeys, + userLang as string + ); + + // 캐시에 저장 + translationCache.set(cacheKey, { + data: defaultTranslations, + timestamp: Date.now(), + }); + + res.status(200).json({ + success: true, + data: defaultTranslations, + message: "기본값으로 다국어 텍스트 조회 성공", + fromCache: false, + }); + return; + } + + // 2. 모든 key_id를 추출 + const keyIds = langKeyMasters.map((master) => master.key_id); + + // 3. 요청된 언어와 한국어 번역을 한번에 조회 + const translations = await prisma.$queryRaw` + SELECT + mlt.key_id, + mlt.lang_code, + mlt.lang_text, + mlkm.lang_key + FROM multi_lang_text mlt + JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id + WHERE mlt.key_id = ANY(${keyIds}::numeric[]) + AND mlt.lang_code IN (${userLang}::varchar, 'KR') + ORDER BY + mlt.key_id, + CASE WHEN mlt.lang_code = ${userLang}::varchar THEN 1 ELSE 2 END + `; + + logger.info("번역 텍스트 배치 조회 결과", { + keyIds: keyIds.length, + translations: translations.length, + }); + + // 4. 결과를 키별로 정리 + const result: Record = {}; + + for (const langKey of langKeys) { + const master = langKeyMasters.find((m) => m.lang_key === langKey); + + if (master) { + const keyId = master.key_id; + + // 요청된 언어 번역 찾기 + let translation = translations.find( + (t) => t.key_id === keyId && t.lang_code === userLang + ); + + // 요청된 언어가 없으면 한국어 번역 찾기 + if (!translation) { + translation = translations.find( + (t) => t.key_id === keyId && t.lang_code === "KR" + ); + } + + // 번역이 있으면 사용, 없으면 기본값 + if (translation) { + result[langKey] = translation.lang_text; + } else { + result[langKey] = getDefaultTranslation(langKey, userLang as string); + } + } else { + // 마스터 데이터가 없으면 기본값 + result[langKey] = getDefaultTranslation(langKey, userLang as string); + } + } + + // 5. 캐시에 저장 + translationCache.set(cacheKey, { + data: result, + timestamp: Date.now(), + }); + + logger.info("다국어 텍스트 배치 조회 완료", { + requestedKeys: langKeys.length, + resultKeys: Object.keys(result).length, + cacheKey, + }); + + res.status(200).json({ + success: true, + data: result, + message: "다국어 텍스트 배치 조회 성공", + fromCache: false, + }); + } catch (error) { + logger.error("다국어 텍스트 배치 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; /** * GET /api/multilang/user-text/:companyCode/:menuCode/:langKey - * 다국어 텍스트 조회 API + * 단일 다국어 텍스트 조회 API (하위 호환성 유지) */ export const getUserText = async (req: AuthenticatedRequest, res: Response) => { try { const { companyCode, menuCode, langKey } = req.params; const { userLang } = req.query; - logger.info("다국어 텍스트 조회 요청", { + logger.info("단일 다국어 텍스트 조회 요청", { companyCode, menuCode, langKey, @@ -19,22 +204,20 @@ export const getUserText = async (req: AuthenticatedRequest, res: Response) => { user: req.user, }); - // 임시 더미 데이터 반환 (실제로는 데이터베이스에서 조회) - const dummyText = `${menuCode}_${langKey}_${userLang}`; + // 배치 API를 사용하여 단일 키 조회 + const batchResult = await getBatchTranslations( + { + ...req, + body: { langKeys: [langKey] }, + query: { companyCode, menuCode, userLang }, + } as any, + res + ); - const response = { - success: true, - data: dummyText, - message: "다국어 텍스트 조회 성공", - }; - - logger.info("다국어 텍스트 조회 성공", { - text: dummyText, - }); - - res.status(200).json(response); + // 배치 API에서 이미 응답을 보냈으므로 여기서는 아무것도 하지 않음 + return; } catch (error) { - logger.error("다국어 텍스트 조회 실패", { error }); + logger.error("단일 다국어 텍스트 조회 실패", { error }); res.status(500).json({ success: false, message: "다국어 텍스트 조회 중 오류가 발생했습니다.", @@ -42,3 +225,101 @@ export const getUserText = async (req: AuthenticatedRequest, res: Response) => { }); } }; + +/** + * 기본 번역 텍스트 반환 (개별 키) + */ +function getDefaultTranslation(langKey: string, userLang: string): string { + const defaultKoreanTexts: Record = { + "button.add": "추가", + "button.add.top.level": "최상위 메뉴 추가", + "button.add.sub": "하위 메뉴 추가", + "button.edit": "수정", + "button.delete": "삭제", + "button.cancel": "취소", + "button.save": "저장", + "button.register": "등록", + "form.menu.name": "메뉴명", + "form.menu.url": "URL", + "form.menu.description": "설명", + "form.menu.type": "메뉴 타입", + "form.status": "상태", + "form.company": "회사", + "table.header.menu.name": "메뉴명", + "table.header.menu.url": "URL", + "table.header.status": "상태", + "table.header.company": "회사", + "table.header.actions": "작업", + "filter.company": "회사", + "filter.search": "검색", + "filter.reset": "초기화", + "menu.type.title": "메뉴 타입", + "menu.type.admin": "관리자", + "menu.type.user": "사용자", + "status.active": "활성화", + "status.inactive": "비활성화", + "form.lang.key": "언어 키", + "form.lang.key.select": "언어 키 선택", + "form.menu.name.placeholder": "메뉴명을 입력하세요", + "form.menu.url.placeholder": "URL을 입력하세요", + "form.menu.description.placeholder": "설명을 입력하세요", + "form.menu.sequence": "순서", + "form.menu.sequence.placeholder": "순서를 입력하세요", + "form.status.active": "활성", + "form.status.inactive": "비활성", + "form.company.select": "회사 선택", + "form.company.common": "공통", + "form.company.submenu.note": "하위메뉴는 회사별로 관리됩니다", + "filter.company.common": "공통", + "filter.search.placeholder": "검색어를 입력하세요", + "modal.menu.register.title": "메뉴 등록", + }; + + return defaultKoreanTexts[langKey] || langKey; +} + +/** + * 기본 번역 텍스트 반환 (배치) + */ +function getDefaultTranslations( + langKeys: string[], + userLang: string +): Record { + const result: Record = {}; + + for (const langKey of langKeys) { + result[langKey] = getDefaultTranslation(langKey, userLang); + } + + return result; +} + +/** + * 캐시 초기화 (개발/테스트용) + */ +export const clearCache = async (req: AuthenticatedRequest, res: Response) => { + try { + const beforeSize = translationCache.size; + translationCache.clear(); + + logger.info("다국어 캐시 초기화 완료", { + beforeSize, + afterSize: 0, + user: req.user, + }); + + res.status(200).json({ + success: true, + message: "캐시가 초기화되었습니다.", + beforeSize, + afterSize: 0, + }); + } catch (error) { + logger.error("캐시 초기화 실패", { error }); + res.status(500).json({ + success: false, + message: "캐시 초기화 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 8d2d202e..b7ac1a3e 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -3,9 +3,15 @@ import { getAdminMenus, getUserMenus, getMenuInfo, + saveMenu, // 메뉴 추가 + updateMenu, // 메뉴 수정 + deleteMenu, // 메뉴 삭제 + deleteMenusBatch, // 메뉴 일괄 삭제 getUserList, getCompanyList, + getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getUserLocale, + setUserLocale, getLanguageList, getLangKeyList, getLangTextList, @@ -29,15 +35,21 @@ router.use(authenticateToken); router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); router.get("/menus/:menuId", getMenuInfo); +router.post("/menus", saveMenu); // 메뉴 추가 +router.put("/menus/:menuId", updateMenu); // 메뉴 수정 +router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!) +router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 // 사용자 관리 API router.get("/users", getUserList); // 회사 관리 API router.get("/companies", getCompanyList); +router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회 // 사용자 로케일 API router.get("/user-locale", getUserLocale); +router.post("/user-locale", setUserLocale); // 다국어 관리 API router.get("/multilang/languages", getLanguageList); diff --git a/backend-node/src/routes/multilangRoutes.ts b/backend-node/src/routes/multilangRoutes.ts index 7b68faf6..30782c10 100644 --- a/backend-node/src/routes/multilangRoutes.ts +++ b/backend-node/src/routes/multilangRoutes.ts @@ -1,5 +1,9 @@ import { Router } from "express"; -import { getUserText } from "../controllers/multilangController"; +import { + getUserText, + getBatchTranslations, + clearCache, +} from "../controllers/multilangController"; import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); @@ -10,4 +14,10 @@ router.use(authenticateToken); // 다국어 텍스트 API router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); +// 다국어 텍스트 배치 조회 API (새로운 방식) +router.post("/batch", getBatchTranslations); + +// 캐시 초기화 API (개발/테스트용) +router.delete("/cache", clearCache); + export default router; diff --git a/backend-node/src/types/common.ts b/backend-node/src/types/common.ts index a703b91d..6b66e97a 100644 --- a/backend-node/src/types/common.ts +++ b/backend-node/src/types/common.ts @@ -5,6 +5,7 @@ export interface ApiResponse { success: boolean; data?: T; message?: string; + total?: number; error?: { code: string; details?: any; diff --git a/frontend/app/(main)/admin/company/page.tsx b/frontend/app/(main)/admin/company/page.tsx new file mode 100644 index 00000000..79e92516 --- /dev/null +++ b/frontend/app/(main)/admin/company/page.tsx @@ -0,0 +1,8 @@ +import { CompanyManagement } from "@/components/admin/CompanyManagement"; + +/** + * 회사 관리 페이지 + */ +export default function CompanyPage() { + return ; +} diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx index 080f8088..89738fb2 100644 --- a/frontend/app/(main)/admin/layout.tsx +++ b/frontend/app/(main)/admin/layout.tsx @@ -359,43 +359,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const loadTranslations = async () => { try { - // 전역 언어 상태에서 현재 언어 가져오기 - const currentUserLang = (typeof window !== "undefined" && (window as any).__GLOBAL_USER_LANG) || userLang || "KR"; + // 현재 사용자 언어 사용 + const currentUserLang = userLang || "en"; console.log("🌐 Admin Layout 번역 로드 시작", { userLang, - globalUserLang: typeof window !== "undefined" && (window as any).__GLOBAL_USER_LANG, currentUserLang, }); - // API 직접 호출로 현재 언어 사용 + // API 직접 호출로 현재 언어 사용 (배치 조회 방식) const companyCode = "*"; - const [titleResponse, descriptionResponse] = await Promise.all([ - apiClient.get( - `/multilang/user-text/${companyCode}/MENU_MANAGEMENT/${MENU_MANAGEMENT_KEYS.TITLE}?userLang=${currentUserLang}`, - ), - apiClient.get( - `/multilang/user-text/${companyCode}/MENU_MANAGEMENT/${MENU_MANAGEMENT_KEYS.DESCRIPTION}?userLang=${currentUserLang}`, - ), - ]); + try { + // 배치 조회 API 사용 + const response = await apiClient.post( + "/multilang/batch", + { + langKeys: [MENU_MANAGEMENT_KEYS.TITLE, MENU_MANAGEMENT_KEYS.DESCRIPTION], + }, + { + params: { + companyCode, + menuCode: "MENU_MANAGEMENT", + userLang: currentUserLang, + }, + }, + ); - const titleData = titleResponse.data; - const descriptionData = descriptionResponse.data; + if (response.data.success && response.data.data) { + const translations = response.data.data; + const title = translations[MENU_MANAGEMENT_KEYS.TITLE] || "메뉴 관리"; + const description = + translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다."; - const title = titleData.success ? titleData.data : "메뉴 관리"; - const description = descriptionData.success ? descriptionData.data : "시스템의 메뉴 구조와 권한을 관리합니다."; + // 번역 캐시에 저장 + setTranslationCache(currentUserLang, translations); - // 번역 캐시에 저장 - const translations = { - [MENU_MANAGEMENT_KEYS.TITLE]: title, - [MENU_MANAGEMENT_KEYS.DESCRIPTION]: description, - }; - setTranslationCache(currentUserLang, translations); + // 상태 업데이트 + setMenuTranslations({ title, description }); - // 상태 업데이트 - setMenuTranslations({ title, description }); - - console.log("🌐 Admin Layout 번역 로드 완료", { title, description, userLang: currentUserLang }); + console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang: currentUserLang }); + } else { + // 기본값 사용 + const title = "메뉴 관리"; + const description = "시스템의 메뉴 구조와 권한을 관리합니다."; + setMenuTranslations({ title, description }); + console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: currentUserLang }); + } + } catch (error) { + console.error("❌ Admin Layout 배치 번역 로드 실패:", error); + // 오류 시 기본값 사용 + const title = "메뉴 관리"; + const description = "시스템의 메뉴 구조와 권한을 관리합니다."; + setMenuTranslations({ title, description }); + } } catch (error) { console.error("❌ Admin Layout 번역 로드 실패:", error); } diff --git a/frontend/components/admin/CompanyDeleteDialog.tsx b/frontend/components/admin/CompanyDeleteDialog.tsx index 1dbd6016..7f8a9145 100644 --- a/frontend/components/admin/CompanyDeleteDialog.tsx +++ b/frontend/components/admin/CompanyDeleteDialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import { CompanyDeleteState } from "@/types/company"; import { Button } from "@/components/ui/button"; diff --git a/frontend/components/admin/CompanyManagement.tsx b/frontend/components/admin/CompanyManagement.tsx index 0c1509ec..77692f0a 100644 --- a/frontend/components/admin/CompanyManagement.tsx +++ b/frontend/components/admin/CompanyManagement.tsx @@ -1,5 +1,6 @@ +"use client"; + import { useCompanyManagement } from "@/hooks/useCompanyManagement"; -import { MOCK_COMPANIES } from "@/constants/company"; import { CompanyToolbar } from "./CompanyToolbar"; import { CompanyTable } from "./CompanyTable"; import { CompanyFormModal } from "./CompanyFormModal"; @@ -48,7 +49,7 @@ export function CompanyManagement() { {/* 툴바 - 검색, 필터, 등록 버튼 */} = ({ level, parentCompanyCode, }) => { + console.log("🎯 MenuFormModal 렌더링 - Props:", { + isOpen, + menuId, + parentId, + menuType, + level, + parentCompanyCode, + }); + + console.log("🔍 MenuFormModal 컴포넌트 마운트됨"); + const [formData, setFormData] = useState({ parentObjId: parentId || "0", menuNameKor: "", menuUrl: "", menuDesc: "", seq: 1, - menuType: menuType || "1", - status: "active", - companyCode: "", - langKey: "", // 다국어 키 추가 + menuType: "1", + status: "ACTIVE", + companyCode: parentCompanyCode || "none", + langKey: "", }); + const [loading, setLoading] = useState(false); const [isEdit, setIsEdit] = useState(false); const [companies, setCompanies] = useState([]); @@ -57,110 +69,7 @@ export const MenuFormModal: React.FC = ({ const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false); const [langKeySearchText, setLangKeySearchText] = useState(""); - // 회사 목록 로드 - useEffect(() => { - if (isOpen) { - loadCompanies(); - } - }, [isOpen]); - - // 다국어 키 목록 로드 - useEffect(() => { - if (isOpen && formData.companyCode) { - loadLangKeys(); - } - }, [isOpen, formData.companyCode]); - - // 드롭다운 외부 클릭 시 닫기 - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Element; - if (!target.closest(".langkey-dropdown")) { - setIsLangKeyDropdownOpen(false); - setLangKeySearchText(""); - } - }; - - if (isLangKeyDropdownOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isLangKeyDropdownOpen]); - - const loadCompanies = async () => { - try { - const companyList = await companyAPI.getList({ status: "active" }); - setCompanies(companyList); - } catch (error) { - console.error("회사 목록 로딩 오류:", error); - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST)); - } - }; - - const loadLangKeys = async () => { - console.log(`🔤 다국어 키 목록 조회 시작 - companyCode:`, formData.companyCode); - try { - const response = await menuApi.getLangKeys({ - companyCode: formData.companyCode === "none" ? "*" : formData.companyCode, - }); - - if (response.success && response.data) { - // 활성화된 다국어 키만 필터링 - const activeKeys = response.data.filter((key) => key.isActive === "Y"); - console.log(`🔤 다국어 키 목록 조회 성공:`, activeKeys.length, "개 (활성화된 키)"); - setLangKeys(activeKeys); - } - } catch (error) { - console.error("❌ 다국어 키 목록 로딩 오류:", error); - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST)); - setLangKeys([]); - } - }; - - useEffect(() => { - console.log("MenuFormModal useEffect - menuId:", menuId, "parentId:", parentId, "menuType:", menuType); - - if (menuId) { - console.log("메뉴 수정 모드 - menuId:", menuId); - setIsEdit(true); - loadMenuData(); - } else { - console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); - setIsEdit(false); - - // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) - let defaultMenuType = "1"; // 기본값은 사용자 - if (menuType === "0" || menuType === "admin") { - defaultMenuType = "0"; // 관리자 - } else if (menuType === "1" || menuType === "user") { - defaultMenuType = "1"; // 사용자 - } - - setFormData({ - parentObjId: parentId || "0", - menuNameKor: "", - menuUrl: "", - menuDesc: "", - seq: 1, - menuType: defaultMenuType, - status: "ACTIVE", // 기본값은 활성화 - companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정 - langKey: "", // 다국어 키 초기화 - }); - - console.log("메뉴 등록 기본값 설정:", { - parentObjId: parentId || "0", - menuType: defaultMenuType, - status: "ACTIVE", - companyCode: "", - langKey: "", - }); - } - }, [menuId, parentId, menuType]); - + // loadMenuData 함수를 먼저 정의 const loadMenuData = async () => { console.log("loadMenuData 호출됨 - menuId:", menuId); if (!menuId) { @@ -246,6 +155,129 @@ export const MenuFormModal: React.FC = ({ } }; + // useEffect를 loadMenuData 함수 정의 후로 이동 + useEffect(() => { + console.log("🚀 MenuFormModal useEffect 실행됨!"); + console.log("📋 useEffect 파라미터:", { menuId, parentId, menuType }); + console.log("MenuFormModal useEffect - menuId:", menuId, "parentId:", parentId, "menuType:", menuType); + + if (menuId) { + console.log("메뉴 수정 모드 - menuId:", menuId); + setIsEdit(true); + loadMenuData(); + } else { + console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); + setIsEdit(false); + + // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) + let defaultMenuType = "1"; // 기본값은 사용자 + if (menuType === "0" || menuType === "admin") { + defaultMenuType = "0"; // 관리자 + } else if (menuType === "1" || menuType === "user") { + defaultMenuType = "1"; // 사용자 + } + + setFormData({ + parentObjId: parentId || "0", + menuNameKor: "", + menuUrl: "", + menuDesc: "", + seq: 1, + menuType: defaultMenuType, + status: "ACTIVE", // 기본값은 활성화 + companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정 + langKey: "", // 다국어 키 초기화 + }); + + console.log("메뉴 등록 기본값 설정:", { + parentObjId: parentId || "0", + menuType: defaultMenuType, + status: "ACTIVE", + companyCode: "", + langKey: "", + }); + } + }, [menuId, parentId, menuType]); + + // 강제로 useEffect 실행시키기 위한 별도 useEffect + useEffect(() => { + console.log("🔧 강제 useEffect 실행 - 컴포넌트 마운트됨"); + console.log("🔧 현재 props:", { isOpen, menuId, parentId, menuType }); + + // isOpen이 true일 때만 실행 + if (isOpen && menuId) { + console.log("🔧 모달이 열렸고 menuId가 있음 - 강제 실행"); + // 약간의 지연 후 실행 + setTimeout(() => { + console.log("🔧 setTimeout으로 loadMenuData 실행"); + loadMenuData(); + }, 100); + } + }, [isOpen]); // isOpen만 의존성으로 설정 + + // 회사 목록 로드 + useEffect(() => { + if (isOpen) { + loadCompanies(); + } + }, [isOpen]); + + // 다국어 키 목록 로드 + useEffect(() => { + if (isOpen && formData.companyCode) { + loadLangKeys(); + } + }, [isOpen, formData.companyCode]); + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + if (!target.closest(".langkey-dropdown")) { + setIsLangKeyDropdownOpen(false); + setLangKeySearchText(""); + } + }; + + if (isLangKeyDropdownOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isLangKeyDropdownOpen]); + + const loadCompanies = async () => { + try { + const companyList = await companyAPI.getList({ status: "active" }); + setCompanies(companyList); + } catch (error) { + console.error("회사 목록 로딩 오류:", error); + toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST)); + } + }; + + const loadLangKeys = async () => { + console.log("🔤 다국어 키 목록 조회 시작 - companyCode:", formData.companyCode); + try { + const response = await menuApi.getLangKeys({ + companyCode: formData.companyCode === "none" ? "*" : formData.companyCode, + }); + + if (response.success && response.data) { + // 활성화된 다국어 키만 필터링 + const activeKeys = response.data.filter((key) => key.isActive === "Y"); + console.log("🔤 다국어 키 목록 조회 성공:", activeKeys.length, "개 (활성화된 키)"); + setLangKeys(activeKeys); + } + } catch (error) { + console.error("❌ 다국어 키 목록 로딩 오류:", error); + toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST)); + setLangKeys([]); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -270,7 +302,18 @@ export const MenuFormModal: React.FC = ({ }; console.log("저장할 데이터:", submitData); - const response = await menuApi.saveMenu(submitData); + + let response; + + if (isEdit && menuId) { + // 수정 모드: updateMenu API 호출 + console.log("🔧 메뉴 수정 API 호출:", menuId); + response = await menuApi.updateMenu(menuId, submitData); + } else { + // 추가 모드: saveMenu API 호출 + console.log("➕ 메뉴 추가 API 호출"); + response = await menuApi.saveMenu(submitData); + } if (response.success) { toast.success(response.message); @@ -280,6 +323,7 @@ export const MenuFormModal: React.FC = ({ toast.error(response.message); } } catch (error) { + console.error("메뉴 저장/수정 실패:", error); toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED)); } finally { setLoading(false); diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx index 03bfd76b..79ba743c 100644 --- a/frontend/components/admin/MenuManagement.tsx +++ b/frontend/components/admin/MenuManagement.tsx @@ -460,13 +460,34 @@ export const MenuManagement: React.FC = () => { }; const handleEditMenu = (menuId: string) => { - setFormData({ - menuId, - parentId: "", - menuType: "", - level: 0, - parentCompanyCode: "", - }); + console.log("🔧 메뉴 수정 시작 - menuId:", menuId); + + // 현재 메뉴 정보 찾기 + const currentMenus = selectedMenuType === "admin" ? adminMenus : userMenus; + const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId); + + if (menuToEdit) { + console.log("수정할 메뉴 정보:", menuToEdit); + + setFormData({ + menuId: menuId, + parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "", + menuType: selectedMenuType, // 현재 선택된 메뉴 타입 + level: 0, // 기본값 + parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "", + }); + + console.log("설정된 formData:", { + menuId: menuId, + parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "", + menuType: selectedMenuType, + level: 0, + parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "", + }); + } else { + console.error("수정할 메뉴를 찾을 수 없음:", menuId); + } + setFormModalOpen(true); }; diff --git a/frontend/hooks/useCompanyManagement.ts b/frontend/hooks/useCompanyManagement.ts index 44be5326..35420ca0 100644 --- a/frontend/hooks/useCompanyManagement.ts +++ b/frontend/hooks/useCompanyManagement.ts @@ -38,14 +38,19 @@ export const useCompanyManagement = () => { setError(null); try { + // 실제 데이터베이스에서 회사 목록 조회 const searchParams = { company_name: searchFilter.company_name, status: searchFilter.status === "all" ? undefined : searchFilter.status, }; + // 더미 데이터 대신 실제 API 호출 const data = await companyAPI.getList(searchParams); setCompanies(data); + + console.log("✅ 실제 DB에서 회사 목록 조회 성공:", data.length, "개"); } catch (err) { + console.error("❌ 회사 목록 조회 실패:", err); setError(err instanceof Error ? err.message : "회사 목록 조회에 실패했습니다."); setCompanies([]); } finally { diff --git a/frontend/hooks/useMultiLang.ts b/frontend/hooks/useMultiLang.ts index 6c1efa4e..8679bf5a 100644 --- a/frontend/hooks/useMultiLang.ts +++ b/frontend/hooks/useMultiLang.ts @@ -1,38 +1,36 @@ import { useState, useEffect } from "react"; -import { setTranslationCache } from "@/lib/utils/multilang"; import { apiClient } from "@/lib/api/client"; -interface UseMultiLangOptions { - companyCode?: string; - defaultLang?: string; -} - -// 전역 언어 상태 관리 +// 전역 언어 상태 (다른 컴포넌트에서 접근 가능) let globalUserLang = "KR"; let globalChangeLangCallback: ((lang: string) => void) | null = null; -export function useMultiLang(options: UseMultiLangOptions = {}) { - const { companyCode = "ILSHIN", defaultLang = "KR" } = options; - const [userLang, setUserLang] = useState(globalUserLang || defaultLang); +export const useMultiLang = (options: { companyCode?: string } = {}) => { + const [userLang, setUserLang] = useState("KR"); + const companyCode = options.companyCode || "*"; - // 전역 언어 상태 업데이트 + // 전역 언어 상태 동기화 useEffect(() => { - globalUserLang = userLang; - // window 객체에 전역 언어 상태 저장 (API 클라이언트에서 접근용) - if (typeof window !== "undefined") { - (window as any).__GLOBAL_USER_LANG = userLang; - console.log("전역 언어 상태 설정:", userLang); + if (globalUserLang !== userLang) { + setUserLang(globalUserLang); } + }, [globalUserLang]); + + // 언어 변경 시 전역 콜백 호출 + useEffect(() => { if (globalChangeLangCallback) { globalChangeLangCallback(userLang); } }, [userLang]); - // API 기본 URL 설정 - const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"; - - // 브라우저 언어 감지 + // 사용자 로케일 조회 (한 번만 실행) useEffect(() => { + // 이미 로케일이 설정되어 있으면 중복 호출 방지 + if (globalUserLang && globalUserLang !== "KR") { + setUserLang(globalUserLang); + return; + } + const fetchUserLocale = async () => { try { console.log("🔍 사용자 로케일 조회 시작"); @@ -42,47 +40,31 @@ export function useMultiLang(options: UseMultiLangOptions = {}) { const userLocale = response.data.data; console.log("✅ 사용자 로케일 조회 성공:", userLocale); - // 사용자 로케일을 데이터베이스 언어 코드로 매핑 - const langMapping: Record = { - ko: "KR", - en: "US", - ja: "JP", - zh: "CN", - }; - - const mappedLang = langMapping[userLocale] || userLocale; - console.log("🔄 언어 매핑:", userLocale, "->", mappedLang); - setUserLang(mappedLang); + // 데이터베이스의 locale 값을 그대로 사용 (매핑 없음) + setUserLang(userLocale); + globalUserLang = userLocale; // 전역 상태도 업데이트 return; } // API 호출 실패 시 브라우저 언어 사용 console.warn("⚠️ 사용자 로케일 조회 실패, 브라우저 언어 사용"); const browserLang = navigator.language.split("-")[0]; - const langMapping: Record = { - ko: "KR", - en: "US", - ja: "JP", - zh: "CN", - }; - if (langMapping[browserLang]) { - setUserLang(langMapping[browserLang]); + // 브라우저 언어를 그대로 사용 (매핑 없음) + if (["ko", "en", "ja", "zh"].includes(browserLang)) { + setUserLang(browserLang); + globalUserLang = browserLang; } } catch (error) { console.error("❌ 사용자 로케일 조회 중 오류:", error); // 오류 시 브라우저 언어 사용 const browserLang = navigator.language.split("-")[0]; - const langMapping: Record = { - ko: "KR", - en: "US", - ja: "JP", - zh: "CN", - }; - if (langMapping[browserLang]) { - setUserLang(langMapping[browserLang]); + // 브라우저 언어를 그대로 사용 (매핑 없음) + if (["ko", "en", "ja", "zh"].includes(browserLang)) { + setUserLang(browserLang); + globalUserLang = browserLang; } } }; @@ -90,42 +72,69 @@ export function useMultiLang(options: UseMultiLangOptions = {}) { fetchUserLocale(); }, []); - // 다국어 텍스트 가져오기 + // 다국어 텍스트 가져오기 (배치 조회 방식) const getText = async (menuCode: string, langKey: string, fallback?: string): Promise => { - console.log(`🔍 다국어 텍스트 요청:`, { menuCode, langKey, userLang, companyCode }); + console.log(`🔍 다국어 텍스트 요청 (배치 방식):`, { menuCode, langKey, userLang, companyCode }); try { - const url = `/multilang/user-text/${companyCode}/${menuCode}/${langKey}?userLang=${userLang}`; - console.log(`📡 API 요청 URL:`, url); + // 배치 조회 API 사용 + const response = await apiClient.post( + "/multilang/batch", + { + langKeys: [langKey], + }, + { + params: { + companyCode, + menuCode, + userLang, + }, + }, + ); - const response = await apiClient.get(url); + console.log(`📡 배치 API 응답 상태:`, response.status, response.statusText); - console.log(`📡 API 응답 상태:`, response.status, response.statusText); - - if (response.data.success && response.data.data) { - // 개별 번역 텍스트를 캐시에 저장 + if (response.data.success && response.data.data && response.data.data[langKey]) { + // 번역 텍스트를 캐시에 저장 const cacheKey = `${menuCode}.${langKey}`; const currentCache = (window as any).__TRANSLATION_CACHE || {}; - currentCache[cacheKey] = response.data.data; + currentCache[cacheKey] = response.data.data[langKey]; (window as any).__TRANSLATION_CACHE = currentCache; - return response.data.data; + return response.data.data[langKey]; } // 실패 시 fallback 또는 키 반환 - console.log(`🔄 API 성공했지만 데이터 없음, fallback 반환:`, fallback || langKey); + console.log(`🔄 배치 API 성공했지만 데이터 없음, fallback 반환:`, fallback || langKey); return fallback || langKey; } catch (error) { - console.error("❌ 다국어 텍스트 조회 실패:", error); + console.error("❌ 다국어 텍스트 배치 조회 실패:", error); console.log(`🔄 에러 시 fallback 반환:`, fallback || langKey); return fallback || langKey; } }; // 언어 변경 - const changeLang = (newLang: string) => { - setUserLang(newLang); - globalUserLang = newLang; + const changeLang = async (newLang: string) => { + try { + // 백엔드에 사용자 로케일 설정 요청 + const response = await apiClient.post("/admin/user-locale", { + locale: newLang, + }); + + if (response.data.success) { + setUserLang(newLang); + globalUserLang = newLang; + console.log("✅ 사용자 로케일 변경 성공:", newLang); + } else { + console.error("❌ 사용자 로케일 변경 실패:", response.data.message); + } + } catch (error) { + console.error("❌ 사용자 로케일 변경 중 오류:", error); + // 오류 시에도 로컬 상태는 변경 + setUserLang(newLang); + globalUserLang = newLang; + } }; // 전역 언어 상태 접근자 @@ -142,4 +151,4 @@ export function useMultiLang(options: UseMultiLangOptions = {}) { getGlobalUserLang, setGlobalChangeLangCallback, }; -} +}; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index d4ef4206..f126df67 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,7 +1,7 @@ import axios, { AxiosResponse, AxiosError } from "axios"; // API 기본 URL 설정 -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api"; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"; // JWT 토큰 관리 유틸리티 const TokenManager = { diff --git a/frontend/lib/api/company.ts b/frontend/lib/api/company.ts index d3da3834..0d9e85ca 100644 --- a/frontend/lib/api/company.ts +++ b/frontend/lib/api/company.ts @@ -3,6 +3,7 @@ */ import { Company, CompanyFormData } from "@/types/company"; +import { apiClient } from "./client"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api"; @@ -15,31 +16,6 @@ interface ApiResponse { errorCode?: string; } -// API 호출 헬퍼 함수 -async function apiCall(endpoint: string, options: RequestInit = {}): Promise> { - try { - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - credentials: "include", // 세션 쿠키를 포함 - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - ...options, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || `HTTP ${response.status}`); - } - - return data; - } catch (error) { - console.error(`API Error [${endpoint}]:`, error); - throw error; - } -} - /** * 회사 목록 조회 */ @@ -54,28 +30,37 @@ export async function getCompanyList(params?: { company_name?: string; status?: } const queryString = searchParams.toString(); - const endpoint = `/admin/companies${queryString ? `?${queryString}` : ""}`; + // 실제 데이터베이스에서 회사 목록 조회하는 엔드포인트 사용 + const endpoint = `/admin/companies/db${queryString ? `?${queryString}` : ""}`; - const response = await apiCall(endpoint); + console.log("🔍 실제 DB에서 회사 목록 조회 API 호출:", endpoint); - if (response.success && response.data) { - return response.data; + try { + const response = await apiClient.get(endpoint); + + if (response.data.success && response.data.data) { + console.log("✅ 실제 DB에서 회사 목록 조회 성공:", response.data.data.length, "개"); + return response.data.data; + } + + throw new Error(response.data.message || "회사 목록 조회에 실패했습니다."); + } catch (error) { + console.error("❌ 실제 DB에서 회사 목록 조회 실패:", error); + throw error; } - - throw new Error(response.message || "회사 목록 조회에 실패했습니다."); } /** * 회사 단건 조회 */ export async function getCompanyInfo(companyCode: string): Promise { - const response = await apiCall(`/admin/companies/${companyCode}`); + const response = await apiClient.get(`/admin/companies/${companyCode}`); - if (response.success && response.data) { - return response.data; + if (response.data.success && response.data.data) { + return response.data.data; } - throw new Error(response.message || "회사 정보 조회에 실패했습니다."); + throw new Error(response.data.message || "회사 정보 조회에 실패했습니다."); } /** @@ -84,22 +69,19 @@ export async function getCompanyInfo(companyCode: string): Promise { export async function createCompany(formData: CompanyFormData): Promise { console.log("회사 등록 요청:", formData); - const response = await apiCall("/admin/companies", { - method: "POST", - body: JSON.stringify(formData), - }); + const response = await apiClient.post("/admin/companies", formData); - if (response.success && response.data) { + if (response.data.success && response.data.data) { console.log("회사 등록 완료:", { - code: response.data.company_code, - name: response.data.company_name, - writer: response.data.writer, + code: response.data.data.company_code, + name: response.data.data.company_name, + writer: response.data.data.writer, }); - return response.data; + return response.data.data; } - throw new Error(response.message || "회사 등록에 실패했습니다."); + throw new Error(response.data.message || "회사 등록에 실패했습니다."); } /** @@ -109,28 +91,23 @@ export async function updateCompany( companyCode: string, formData: Partial & { status?: string }, ): Promise { - const response = await apiCall(`/admin/companies/${companyCode}`, { - method: "PUT", - body: JSON.stringify(formData), - }); + const response = await apiClient.put(`/admin/companies/${companyCode}`, formData); - if (response.success && response.data) { - return response.data; + if (response.data.success && response.data.data) { + return response.data.data; } - throw new Error(response.message || "회사 정보 수정에 실패했습니다."); + throw new Error(response.data.message || "회사 정보 수정에 실패했습니다."); } /** * 회사 삭제 */ export async function deleteCompany(companyCode: string): Promise { - const response = await apiCall(`/admin/companies/${companyCode}`, { - method: "DELETE", - }); + const response = await apiClient.delete(`/admin/companies/${companyCode}`); - if (!response.success) { - throw new Error(response.message || "회사 삭제에 실패했습니다."); + if (!response.data.success) { + throw new Error(response.data.message || "회사 삭제에 실패했습니다."); } } diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 3f76a75f..15057e4a 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -107,6 +107,12 @@ export const menuApi = { return response.data; }, + // 메뉴 수정 + updateMenu: async (menuId: string, menuData: MenuFormData): Promise> => { + const response = await apiClient.put(`/admin/menus/${menuId}`, menuData); + return response.data; + }, + // 메뉴 삭제 deleteMenu: async (menuId: string): Promise> => { const response = await apiClient.delete(`/admin/menus/${menuId}`); @@ -139,7 +145,16 @@ export const menuApi = { menuCode?: string; keyType?: string; }): Promise> => { - const response = await apiClient.get("/multilang/keys", { params }); - return response.data; + console.log("🔍 다국어 키 목록 조회 API 호출:", "/admin/multilang/keys", params); + + try { + // Node.js 백엔드의 실제 라우팅과 일치하도록 수정 + const response = await apiClient.get("/admin/multilang/keys", { params }); + console.log("✅ 다국어 키 목록 조회 성공:", response.data); + return response.data; + } catch (error) { + console.error("❌ 다국어 키 목록 조회 실패:", error); + throw error; + } }, }; diff --git a/frontend/lib/utils/multilang.ts b/frontend/lib/utils/multilang.ts index 561a1eb6..c042ca61 100644 --- a/frontend/lib/utils/multilang.ts +++ b/frontend/lib/utils/multilang.ts @@ -1,25 +1,22 @@ -import { useMultiLang } from "@/hooks/useMultiLang"; import { apiClient } from "../api/client"; // 메뉴 관리 화면 다국어 키 상수 export const MENU_MANAGEMENT_KEYS = { - // 메뉴 타입 관련 - TITLE: "menu.management.title", - DESCRIPTION: "menu.management.description", + // 기본 정보 + TITLE: "title", + DESCRIPTION: "description", MENU_TYPE_TITLE: "menu.type.title", - ADMIN_MENU: "menu.management.admin", - USER_MENU: "menu.management.user", - ADMIN_DESCRIPTION: "menu.management.admin.description", - USER_DESCRIPTION: "menu.management.user.description", MENU_TYPE_ADMIN: "menu.type.admin", MENU_TYPE_USER: "menu.type.user", + ADMIN_MENU: "admin.menu", + USER_MENU: "user.menu", + ADMIN_DESCRIPTION: "admin.description", + USER_DESCRIPTION: "user.description", + LIST_TITLE: "list.title", + LIST_TOTAL: "list.total", + LIST_SEARCH_RESULT: "list.search.result", - // 메뉴 목록 관련 - LIST_TITLE: "menu.list.title", - LIST_TOTAL: "menu.list.total", - LIST_SEARCH_RESULT: "menu.list.search.result", - - // 필터 및 검색 관련 + // 필터 관련 FILTER_COMPANY: "filter.company", FILTER_COMPANY_ALL: "filter.company.all", FILTER_COMPANY_COMMON: "filter.company.common", @@ -43,7 +40,7 @@ export const MENU_MANAGEMENT_KEYS = { BUTTON_REGISTER: "button.register", BUTTON_MODIFY: "button.modify", - // 메뉴 폼 관련 + // 폼 관련 FORM_MENU_TYPE: "form.menu.type", FORM_MENU_TYPE_ADMIN: "form.menu.type.admin", FORM_MENU_TYPE_USER: "form.menu.type.user", @@ -115,215 +112,172 @@ export const MENU_MANAGEMENT_KEYS = { UI_LANGUAGE: "ui.language", } as const; -// 다국어 텍스트 가져오기 함수 -export const useMenuManagementText = () => { - const { getText } = useMultiLang({ companyCode: "*" }); +// 다국어 텍스트 캐시 (메모리 기반) +const translationCache: Record> = {}; - const getMenuText = async (key: string, params?: Record): Promise => { - let text = await getText("MENU_MANAGEMENT", key); +// 배치 조회를 위한 키 수집기 +const pendingKeys: Set = new Set(); +let batchTimeout: NodeJS.Timeout | null = null; +const BATCH_DELAY = 50; // 50ms 지연으로 배치 처리 - // 파라미터 치환 - if (params) { - Object.entries(params).forEach(([paramKey, paramValue]) => { - text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), paramValue.toString()); - }); +/** + * 다국어 텍스트 배치 조회 + * 여러 키를 한번에 조회하여 API 호출 횟수를 대폭 줄임 + */ +async function fetchBatchTranslations( + keys: string[], + companyCode: string = "*", + menuCode: string = "MENU_MANAGEMENT", + userLang: string = "KR", +): Promise> { + try { + console.log(`🚀 배치 조회 시작: ${keys.length}개 키`); + + const response = await apiClient.post( + "/multilang/batch", + { + langKeys: keys, + }, + { + params: { + companyCode, + menuCode, + userLang, + }, + }, + ); + + if (response.data.success) { + console.log(`✅ 배치 조회 성공: ${keys.length}개 키`); + return response.data.data || {}; + } else { + console.error("❌ 배치 조회 실패:", response.data.message); + return {}; } + } catch (error) { + console.error("❌ 배치 조회 오류:", error); + return {}; + } +} - return text; - }; - - return { - getMenuText, - keys: MENU_MANAGEMENT_KEYS, - }; -}; - -// 전역 번역 캐시 -let translationCache: Record> = {}; - -// 번역 캐시 설정 함수 -export const setTranslationCache = (lang: string, translations: Record) => { - translationCache[lang] = translations; -}; - -// 번역 캐시 가져오기 함수 -export const getTranslationCache = (lang: string): Record => { - return translationCache[lang] || {}; -}; - -// 동기적 다국어 텍스트 가져오기 (캐시된 값 사용) -export const getMenuTextSync = (key: string, params?: Record): string => { - // 전역 언어 상태 확인 - const userLang = (typeof window !== "undefined" && (window as any).__GLOBAL_USER_LANG) || "KR"; - - // 현재 언어가 한국어가 아니면 캐시에서 번역 텍스트 찾기 - if (userLang !== "KR") { - // 1. 먼저 전역 캐시에서 찾기 - const cachedTranslations = getTranslationCache(userLang); - if (cachedTranslations[key]) { - let text = cachedTranslations[key]; - - // 파라미터 치환 - if (params) { - Object.entries(params).forEach(([paramKey, paramValue]) => { - text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), paramValue.toString()); - }); - } - - return text; - } - - // 2. 개별 캐시에서 찾기 - const individualCache = (typeof window !== "undefined" && (window as any).__TRANSLATION_CACHE) || {}; - const cacheKey = `MENU_MANAGEMENT.${key}`; - if (individualCache[cacheKey]) { - let text = individualCache[cacheKey]; - - // 파라미터 치환 - if (params) { - Object.entries(params).forEach(([paramKey, paramValue]) => { - text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), paramValue.toString()); - }); - } - - console.log(`✅ 개별 캐시에서 번역 사용:`, { key, result: text, userLang }); - return text; - } - - // 3. 캐시에 없으면 비동기적으로 로드 시도 - console.log(`⚠️ getMenuTextSync: 캐시에 번역이 없습니다. 키: ${key}, 언어: ${userLang}`); - - // 비동기적으로 번역 로드 (백그라운드에서) - if (typeof window !== "undefined") { - const companyCode = "*"; - - apiClient - .get(`/multilang/user-text/${companyCode}/MENU_MANAGEMENT/${key}?userLang=${userLang}`) - .then((response) => { - if (response.data.success && response.data.data) { - // 개별 캐시에 저장 - const currentCache = (window as any).__TRANSLATION_CACHE || {}; - currentCache[cacheKey] = data.data; - (window as any).__TRANSLATION_CACHE = currentCache; - - // 전역 캐시에도 저장 - const globalCache = getTranslationCache(userLang); - globalCache[key] = data.data; - setTranslationCache(userLang, globalCache); - - // 페이지 리렌더링을 위해 이벤트 발생 - window.dispatchEvent( - new CustomEvent("translation-loaded", { - detail: { key, text: data.data, userLang }, - }), - ); - } - }) - .catch((error) => { - console.error(`❌ 백그라운드 번역 로드 실패:`, { key, error }); - }); - } - - // 캐시에 없으면 기본 텍스트에서 찾기 - const defaultTexts: Record = { - [MENU_MANAGEMENT_KEYS.TITLE]: "메뉴 관리", - [MENU_MANAGEMENT_KEYS.DESCRIPTION]: "시스템의 메뉴 구조와 권한을 관리합니다.", - [MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE]: "메뉴 타입", - [MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN]: "관리자", - [MENU_MANAGEMENT_KEYS.MENU_TYPE_USER]: "사용자", - [MENU_MANAGEMENT_KEYS.ADMIN_MENU]: "관리자 메뉴", - [MENU_MANAGEMENT_KEYS.USER_MENU]: "사용자 메뉴", - [MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION]: "시스템 관리 및 설정 메뉴", - [MENU_MANAGEMENT_KEYS.USER_DESCRIPTION]: "일반 사용자 업무 메뉴", - [MENU_MANAGEMENT_KEYS.BUTTON_ADD]: "추가", - [MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL]: "최상위 메뉴 추가", - [MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB]: "하위 메뉴 추가", - [MENU_MANAGEMENT_KEYS.BUTTON_EDIT]: "수정", - [MENU_MANAGEMENT_KEYS.BUTTON_DELETE]: "삭제", - [MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED]: "선택 삭제", - [MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT]: "선택 삭제 ({count})", - [MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING]: "삭제 중...", - [MENU_MANAGEMENT_KEYS.BUTTON_CANCEL]: "취소", - [MENU_MANAGEMENT_KEYS.BUTTON_SAVE]: "저장", - [MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING]: "저장 중...", - [MENU_MANAGEMENT_KEYS.BUTTON_REGISTER]: "등록", - [MENU_MANAGEMENT_KEYS.BUTTON_MODIFY]: "수정", - [MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE]: "메뉴 타입", - [MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN]: "관리자", - [MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER]: "사용자", - [MENU_MANAGEMENT_KEYS.FORM_STATUS]: "상태", - [MENU_MANAGEMENT_KEYS.FORM_STATUS_ACTIVE]: "활성화", - [MENU_MANAGEMENT_KEYS.FORM_STATUS_INACTIVE]: "비활성화", - [MENU_MANAGEMENT_KEYS.FORM_COMPANY]: "회사", - [MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT]: "회사를 선택하세요", - [MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON]: "공통", - [MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE]: "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.", - [MENU_MANAGEMENT_KEYS.FORM_MENU_NAME]: "메뉴명", - [MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER]: "메뉴명을 입력하세요", - [MENU_MANAGEMENT_KEYS.FORM_MENU_URL]: "URL", - [MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER]: "메뉴 URL을 입력하세요", - [MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION]: "설명", - [MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER]: "메뉴 설명을 입력하세요", - [MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE]: "순서", - [MENU_MANAGEMENT_KEYS.FORM_LANG_KEY]: "다국어 키", - [MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT]: "다국어 키를 선택하세요", - [MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE]: "다국어 키 없음", - [MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH]: "다국어 키 검색...", - [MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED]: "선택된 키: {key} - {description}", - [MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE]: "메뉴 등록", - [MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE]: "메뉴 수정", - [MENU_MANAGEMENT_KEYS.MODAL_DELETE_TITLE]: "메뉴 삭제", - [MENU_MANAGEMENT_KEYS.MODAL_DELETE_DESCRIPTION]: "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", - [MENU_MANAGEMENT_KEYS.MODAL_DELETE_BATCH_DESCRIPTION]: - "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_SELECT]: "선택", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME]: "메뉴명", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL]: "URL", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_TYPE]: "메뉴 타입", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS]: "상태", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY]: "회사", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE]: "순서", - [MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS]: "작업", - [MENU_MANAGEMENT_KEYS.STATUS_ACTIVE]: "활성화", - [MENU_MANAGEMENT_KEYS.STATUS_INACTIVE]: "비활성화", - [MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED]: "미지정", - [MENU_MANAGEMENT_KEYS.MESSAGE_LOADING]: "로딩 중...", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_PROCESSING]: "메뉴 삭제 중...", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_SUCCESS]: "메뉴가 저장되었습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED]: "메뉴 저장에 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_SUCCESS]: "메뉴가 삭제되었습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_FAILED]: "메뉴 삭제에 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_SUCCESS]: - "✅ {count}개의 메뉴(및 하위 메뉴)가 성공적으로 삭제되었습니다!", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_PARTIAL]: "⚠️ {success}개 삭제 성공, {failed}개 삭제 실패", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_SUCCESS]: "메뉴 상태가 변경되었습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_FAILED]: "메뉴 상태 변경에 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED]: "메뉴명을 입력해주세요.", - [MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED]: "회사를 선택해주세요.", - [MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_SELECT_MENU_DELETE]: "삭제할 메뉴를 선택해주세요.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_LIST]: "메뉴 목록을 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO]: "메뉴 정보를 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST]: "회사 목록을 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST]: "다국어 키 목록을 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.UI_EXPAND]: "펼치기", - [MENU_MANAGEMENT_KEYS.UI_COLLAPSE]: "접기", - [MENU_MANAGEMENT_KEYS.UI_MENU_COLLAPSE]: "메뉴 접기", - [MENU_MANAGEMENT_KEYS.UI_LANGUAGE]: "언어", - }; - - let text = defaultTexts[key] || key; - - // 파라미터 치환 - if (params) { - Object.entries(params).forEach(([paramKey, paramValue]) => { - text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), paramValue.toString()); - }); - } - - return text; +/** + * 개별 다국어 텍스트 조회 (배치 처리) + * 실제로는 배치로 처리되어 API 호출 횟수가 대폭 감소 + */ +export async function getMultilangText( + key: string, + companyCode: string = "*", + menuCode: string = "MENU_MANAGEMENT", + userLang: string = "KR", +): Promise { + // 1. 캐시에서 먼저 확인 + const cacheKey = `${userLang}_${companyCode}_${menuCode}`; + if (translationCache[cacheKey]?.[key]) { + return translationCache[cacheKey][key]; } - // 한국어인 경우 기본 텍스트 반환 + // 2. 기본 텍스트에서 확인 + const defaultText = getDefaultText(key); + if (defaultText) { + return defaultText; + } + + // 3. 배치 처리에 추가 + pendingKeys.add(key); + + // 4. 배치 타임아웃 설정 + if (batchTimeout) { + clearTimeout(batchTimeout); + } + + return new Promise((resolve) => { + batchTimeout = setTimeout(async () => { + try { + const keysToFetch = Array.from(pendingKeys); + pendingKeys.clear(); + + if (keysToFetch.length > 0) { + const translations = await fetchBatchTranslations(keysToFetch, companyCode, menuCode, userLang); + + // 캐시에 저장 + if (!translationCache[cacheKey]) { + translationCache[cacheKey] = {}; + } + + Object.assign(translationCache[cacheKey], translations); + + // 요청된 키에 대한 번역 반환 + if (translations[key]) { + resolve(translations[key]); + } else { + resolve(defaultText || key); + } + } else { + resolve(defaultText || key); + } + } catch (error) { + console.error("❌ 배치 처리 오류:", error); + resolve(defaultText || key); + } + }, BATCH_DELAY); + }); +} + +/** + * 동기적 다국어 텍스트 조회 (캐시에서만) + * UI 렌더링 시 즉시 사용 + */ +export function getMultilangTextSync( + key: string, + companyCode: string = "*", + menuCode: string = "MENU_MANAGEMENT", + userLang: string = "KR", +): string { + // 1. 캐시에서 확인 + const cacheKey = `${userLang}_${companyCode}_${menuCode}`; + if (translationCache[cacheKey]?.[key]) { + return translationCache[cacheKey][key]; + } + + // 2. 기본 텍스트에서 확인 + const defaultText = getDefaultText(key); + if (defaultText) { + return defaultText; + } + + // 3. 캐시에 없으면 비동기적으로 로드 (백그라운드) + if (typeof window !== "undefined") { + getMultilangText(key, companyCode, menuCode, userLang).then((text) => { + // 페이지 리렌더링을 위해 이벤트 발생 + window.dispatchEvent( + new CustomEvent("translation-loaded", { + detail: { key, text, userLang }, + }), + ); + }); + } + + return defaultText || key; +} + +/** + * 메뉴 관리 관련 다국어 텍스트 조회 (배치 처리) + */ +export async function getMenuText(key: string, userLang: string = "KR"): Promise { + return getMultilangText(key, "*", "MENU_MANAGEMENT", userLang); +} + +/** + * 메뉴 관리 관련 다국어 텍스트 동기 조회 + */ +export function getMenuTextSync(key: string, userLang: string = "KR"): string { + return getMultilangTextSync(key, "*", "MENU_MANAGEMENT", userLang); +} + +/** + * 기본 텍스트 반환 (한국어) + */ +function getDefaultText(key: string): string { const defaultTexts: Record = { [MENU_MANAGEMENT_KEYS.TITLE]: "메뉴 관리", [MENU_MANAGEMENT_KEYS.DESCRIPTION]: "시스템의 메뉴 구조와 권한을 관리합니다.", @@ -334,16 +288,6 @@ export const getMenuTextSync = (key: string, params?: Record): stri [MENU_MANAGEMENT_KEYS.USER_MENU]: "사용자 메뉴", [MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION]: "시스템 관리 및 설정 메뉴", [MENU_MANAGEMENT_KEYS.USER_DESCRIPTION]: "일반 사용자 업무 메뉴", - [MENU_MANAGEMENT_KEYS.LIST_TITLE]: "메뉴 목록", - [MENU_MANAGEMENT_KEYS.LIST_TOTAL]: "총 {count}개의 메뉴가 있습니다.", - [MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT]: "검색 결과: {count}개", - [MENU_MANAGEMENT_KEYS.FILTER_COMPANY]: "회사", - [MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL]: "전체 회사", - [MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON]: "공통", - [MENU_MANAGEMENT_KEYS.FILTER_COMPANY_SEARCH]: "회사명 검색...", - [MENU_MANAGEMENT_KEYS.FILTER_SEARCH]: "검색어", - [MENU_MANAGEMENT_KEYS.FILTER_SEARCH_PLACEHOLDER]: "메뉴명 또는 URL 검색", - [MENU_MANAGEMENT_KEYS.FILTER_RESET]: "초기화", [MENU_MANAGEMENT_KEYS.BUTTON_ADD]: "추가", [MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL]: "최상위 메뉴 추가", [MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB]: "하위 메뉴 추가", @@ -396,38 +340,44 @@ export const getMenuTextSync = (key: string, params?: Record): stri [MENU_MANAGEMENT_KEYS.STATUS_ACTIVE]: "활성화", [MENU_MANAGEMENT_KEYS.STATUS_INACTIVE]: "비활성화", [MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED]: "미지정", - [MENU_MANAGEMENT_KEYS.MESSAGE_LOADING]: "로딩 중...", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_PROCESSING]: "메뉴 삭제 중...", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_SUCCESS]: "메뉴가 저장되었습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED]: "메뉴 저장에 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_SUCCESS]: "메뉴가 삭제되었습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_FAILED]: "메뉴 삭제에 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_SUCCESS]: - "✅ {count}개의 메뉴(및 하위 메뉴)가 성공적으로 삭제되었습니다!", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_PARTIAL]: "⚠️ {success}개 삭제 성공, {failed}개 삭제 실패", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_SUCCESS]: "메뉴 상태가 변경되었습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_FAILED]: "메뉴 상태 변경에 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED]: "메뉴명을 입력해주세요.", - [MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED]: "회사를 선택해주세요.", - [MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_SELECT_MENU_DELETE]: "삭제할 메뉴를 선택해주세요.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_LIST]: "메뉴 목록을 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO]: "메뉴 정보를 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST]: "회사 목록을 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST]: "다국어 키 목록을 불러오는데 실패했습니다.", - [MENU_MANAGEMENT_KEYS.UI_EXPAND]: "펼치기", - [MENU_MANAGEMENT_KEYS.UI_COLLAPSE]: "접기", - [MENU_MANAGEMENT_KEYS.UI_MENU_COLLAPSE]: "메뉴 접기", - [MENU_MANAGEMENT_KEYS.UI_LANGUAGE]: "언어", }; - let text = defaultTexts[key] || key; + return defaultTexts[key] || key; +} - // 파라미터 치환 - if (params) { - Object.entries(params).forEach(([paramKey, paramValue]) => { - text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), paramValue.toString()); - }); - } - - return text; +/** + * 번역 캐시 설정 함수 + */ +export const setTranslationCache = (lang: string, translations: Record) => { + translationCache[lang] = translations; +}; + +/** + * 번역 캐시 가져오기 함수 + */ +export const getTranslationCache = (lang: string): Record => { + return translationCache[lang] || {}; +}; + +/** + * 메뉴 관리 다국어 텍스트 훅 (기존 코드와 호환) + */ +export const useMenuManagementText = () => { + const getMenuText = async (key: string, params?: Record): Promise => { + let text = await getMultilangText(key, "*", "MENU_MANAGEMENT", "KR"); + + // 파라미터 치환 + if (params) { + Object.entries(params).forEach(([paramKey, paramValue]) => { + text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), paramValue.toString()); + }); + } + + return text; + }; + + return { + getMenuText, + keys: MENU_MANAGEMENT_KEYS, + }; };