From 6cac3dfa3f5159496642a2f093db0428e3942e72 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 25 Aug 2025 14:24:00 +0900 Subject: [PATCH 001/255] =?UTF-8?q?=ED=9A=8C=EC=82=AC=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EA=B8=B0=EB=8A=A5=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 327 ++++++++++++++++++ backend-node/src/routes/adminRoutes.ts | 6 + 2 files changed, 333 insertions(+) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 3bf5e82e..a73cf117 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1976,3 +1976,330 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { }); } }; + +/** + * POST /api/admin/companies + * 회사 등록 API + * 기존 Java AdminController의 회사 등록 기능 포팅 + */ +export const createCompany = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + logger.info("회사 등록 요청", { + body: req.body, + user: req.user, + }); + + const { company_name } = req.body; + + // 필수 입력값 검증 + if (!company_name || !company_name.trim()) { + res.status(400).json({ + success: false, + message: "회사명을 입력해주세요.", + errorCode: "COMPANY_NAME_REQUIRED", + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 회사명 중복 체크 + const duplicateCheckQuery = ` + SELECT COUNT(*) as count + FROM company_mng + WHERE company_name = $1 + `; + + const duplicateResult = await client.query(duplicateCheckQuery, [ + company_name.trim(), + ]); + + if (parseInt(duplicateResult.rows[0].count) > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 회사명입니다.", + errorCode: "COMPANY_NAME_DUPLICATE", + }); + return; + } + + // 회사 코드 생성 (COMPANY_1, COMPANY_2, ...) + const codeQuery = ` + SELECT COALESCE(MAX(CAST(SUBSTRING(company_code FROM 9) AS INTEGER)), 0) + 1 as next_number + FROM company_mng + WHERE company_code LIKE 'COMPANY_%' + `; + + const codeResult = await client.query(codeQuery); + const nextNumber = codeResult.rows[0].next_number; + const companyCode = `COMPANY_${nextNumber}`; + + // 회사 정보 저장 + const insertQuery = ` + INSERT INTO company_mng ( + company_code, + company_name, + writer, + regdate, + status + ) VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + + const writer = req.user + ? `${req.user.userName}(${req.user.userId})` + : "시스템"; + const insertValues = [ + companyCode, + company_name.trim(), + writer, + new Date(), + "active", + ]; + + const insertResult = await client.query(insertQuery, insertValues); + const createdCompany = insertResult.rows[0]; + + logger.info("회사 등록 성공", { + companyCode: createdCompany.company_code, + companyName: createdCompany.company_name, + writer: createdCompany.writer, + }); + + const response = { + success: true, + message: "회사가 성공적으로 등록되었습니다.", + data: { + company_code: createdCompany.company_code, + company_name: createdCompany.company_name, + writer: createdCompany.writer, + regdate: createdCompany.regdate, + status: createdCompany.status, + }, + }; + + res.status(201).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("회사 등록 실패", { error, body: req.body }); + res.status(500).json({ + success: false, + message: "회사 등록 중 오류가 발생했습니다.", + errorCode: "COMPANY_CREATE_ERROR", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * PUT /api/admin/companies/:companyCode + * 회사 정보 수정 API + */ +export const updateCompany = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + const { company_name, status } = req.body; + + logger.info("회사 정보 수정 요청", { + companyCode, + body: req.body, + user: req.user, + }); + + // 필수 입력값 검증 + if (!company_name || !company_name.trim()) { + res.status(400).json({ + success: false, + message: "회사명을 입력해주세요.", + errorCode: "COMPANY_NAME_REQUIRED", + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 회사명 중복 체크 (자기 자신 제외) + const duplicateCheckQuery = ` + SELECT COUNT(*) as count + FROM company_mng + WHERE company_name = $1 AND company_code != $2 + `; + + const duplicateResult = await client.query(duplicateCheckQuery, [ + company_name.trim(), + companyCode, + ]); + + if (parseInt(duplicateResult.rows[0].count) > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 회사명입니다.", + errorCode: "COMPANY_NAME_DUPLICATE", + }); + return; + } + + // 회사 정보 수정 + const updateQuery = ` + UPDATE company_mng + SET company_name = $1, status = $2 + WHERE company_code = $3 + RETURNING * + `; + + const updateValues = [ + company_name.trim(), + status || "active", + companyCode, + ]; + + const updateResult = await client.query(updateQuery, updateValues); + + if (updateResult.rows.length === 0) { + res.status(404).json({ + success: false, + message: "해당 회사를 찾을 수 없습니다.", + errorCode: "COMPANY_NOT_FOUND", + }); + return; + } + + const updatedCompany = updateResult.rows[0]; + + logger.info("회사 정보 수정 성공", { + companyCode: updatedCompany.company_code, + companyName: updatedCompany.company_name, + status: updatedCompany.status, + }); + + const response = { + success: true, + message: "회사 정보가 수정되었습니다.", + data: { + company_code: updatedCompany.company_code, + company_name: updatedCompany.company_name, + writer: updatedCompany.writer, + regdate: updatedCompany.regdate, + status: updatedCompany.status, + }, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("회사 정보 수정 실패", { error, body: req.body }); + res.status(500).json({ + success: false, + message: "회사 정보 수정 중 오류가 발생했습니다.", + errorCode: "COMPANY_UPDATE_ERROR", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * DELETE /api/admin/companies/:companyCode + * 회사 삭제 API + */ +export const deleteCompany = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + + logger.info("회사 삭제 요청", { + companyCode, + user: req.user, + }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 회사 존재 여부 확인 + const checkQuery = ` + SELECT company_code, company_name + FROM company_mng + WHERE company_code = $1 + `; + + const checkResult = await client.query(checkQuery, [companyCode]); + + if (checkResult.rows.length === 0) { + res.status(404).json({ + success: false, + message: "해당 회사를 찾을 수 없습니다.", + errorCode: "COMPANY_NOT_FOUND", + }); + return; + } + + // 회사 삭제 + const deleteQuery = ` + DELETE FROM company_mng + WHERE company_code = $1 + `; + + await client.query(deleteQuery, [companyCode]); + + logger.info("회사 삭제 성공", { + companyCode, + companyName: checkResult.rows[0].company_name, + }); + + const response = { + success: true, + message: "회사가 삭제되었습니다.", + data: { + company_code: companyCode, + company_name: checkResult.rows[0].company_name, + }, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("회사 삭제 실패", { error }); + res.status(500).json({ + success: false, + message: "회사 삭제 중 오류가 발생했습니다.", + errorCode: "COMPANY_DELETE_ERROR", + 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 9e11161f..fc098728 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -14,6 +14,9 @@ import { saveUser, // 사용자 등록/수정 getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 + createCompany, // 회사 등록 + updateCompany, // 회사 수정 + deleteCompany, // 회사 삭제 getUserLocale, setUserLocale, getLanguageList, @@ -56,6 +59,9 @@ router.get("/departments", getDepartmentList); // 부서 목록 조회 // 회사 관리 API router.get("/companies", getCompanyList); router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회 +router.post("/companies", createCompany); // 회사 등록 +router.put("/companies/:companyCode", updateCompany); // 회사 수정 +router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 // 사용자 로케일 API router.get("/user-locale", getUserLocale); -- 2.43.0 From 96c601a0cf409c72dc3af857de3b3275734659b3 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 25 Aug 2025 15:12:31 +0900 Subject: [PATCH 002/255] =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 106 +- .../src/controllers/multilangController.ts | 1072 ++++++++++++----- backend-node/src/routes/adminRoutes.ts | 24 - backend-node/src/routes/multilangRoutes.ts | 55 +- backend-node/src/services/multilangService.ts | 855 +++++++++++++ backend-node/src/types/multilang.ts | 130 ++ frontend/components/admin/MultiLang.tsx | 49 +- frontend/lib/api/client.ts | 2 +- 8 files changed, 1900 insertions(+), 393 deletions(-) create mode 100644 backend-node/src/services/multilangService.ts create mode 100644 backend-node/src/types/multilang.ts diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a73cf117..bac13d71 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -665,7 +665,7 @@ export async function getLanguageList( } /** - * 다국어 키 목록 조회 (더미 데이터) + * 다국어 키 목록 조회 */ export async function getLangKeyList( req: AuthenticatedRequest, @@ -677,59 +677,61 @@ export async function getLangKeyList( user: req.user, }); - // 더미 데이터 반환 - const langKeys = [ - { - keyId: 1, - companyCode: "ILSHIN", - menuName: "사용자 관리", - langKey: "user.management.title", - description: "사용자 관리 페이지 제목", - isActive: "Y", - createdDate: new Date().toISOString(), - createdBy: "admin", - updatedDate: new Date().toISOString(), - updatedBy: "admin", - }, - { - keyId: 2, - companyCode: "ILSHIN", - menuName: "메뉴 관리", - langKey: "menu.management.title", - description: "메뉴 관리 페이지 제목", - isActive: "Y", - createdDate: new Date().toISOString(), - createdBy: "admin", - updatedDate: new Date().toISOString(), - updatedBy: "admin", - }, - { - keyId: 3, - companyCode: "HUTECH", - menuName: "대시보드", - 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, + // 실제 데이터베이스에서 데이터 조회 + const client = new Client({ + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "ilshin", + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "postgres", }); - res.status(200).json(response); + await client.connect(); + + try { + const query = ` + SELECT + key_id as "keyId", + company_code as "companyCode", + menu_name as "menuName", + lang_key as "langKey", + description, + is_active as "isActive", + created_date as "createdDate", + created_by as "createdBy", + updated_date as "updatedDate", + updated_by as "updatedBy" + FROM multi_lang_key_master + ORDER BY company_code, menu_name, lang_key + `; + + const result = await client.query(query); + const langKeys = result.rows.map((row) => ({ + ...row, + createdDate: row.createdDate + ? new Date(row.createdDate).toISOString() + : undefined, + updatedDate: row.updatedDate + ? new Date(row.updatedDate).toISOString() + : undefined, + })); + + // 프론트엔드에서 기대하는 응답 형식으로 변환 + const response: ApiResponse = { + success: true, + message: "다국어 키 목록 조회 성공", + data: langKeys, + }; + + logger.info("다국어 키 목록 조회 성공", { + totalCount: langKeys.length, + response: response, + }); + + res.status(200).json(response); + } finally { + await client.end(); + } } catch (error) { logger.error("다국어 키 목록 조회 실패:", error); res.status(500).json({ diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 65600672..738d486d 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1,20 +1,761 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; +import { Request, Response } from "express"; 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; -} +import { AuthenticatedRequest } from "../types/auth"; +import { MultiLangService } from "../services/multilangService"; +import { Client } from "pg"; +import { + CreateLanguageRequest, + UpdateLanguageRequest, + CreateLangKeyRequest, + UpdateLangKeyRequest, + SaveLangTextsRequest, + GetUserTextParams, + BatchTranslationRequest, + ApiResponse, +} from "../types/multilang"; /** - * GET /api/multilang/batch - * 다국어 텍스트 배치 조회 API - 여러 키를 한번에 조회 + * GET /api/multilang/languages + * 언어 목록 조회 API + */ +export const getLanguages = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + logger.info("언어 목록 조회 요청", { user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const languages = await multiLangService.getLanguages(); + + const response: ApiResponse = { + success: true, + message: "언어 목록 조회 성공", + data: languages, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("언어 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "언어 목록 조회 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/languages + * 언어 생성 API + */ +export const createLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const languageData: CreateLanguageRequest = req.body; + logger.info("언어 생성 요청", { languageData, user: req.user }); + + // 필수 입력값 검증 + if ( + !languageData.langCode || + !languageData.langName || + !languageData.langNative + ) { + res.status(400).json({ + success: false, + message: "언어 코드, 언어명, 원어명은 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "langCode, langName, langNative are required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const createdLanguage = await multiLangService.createLanguage({ + ...languageData, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 생성되었습니다.", + data: createdLanguage, + }; + + res.status(201).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("언어 생성 실패:", error); + res.status(500).json({ + success: false, + message: "언어 생성 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/languages/:langCode + * 언어 수정 API + */ +export const updateLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + const languageData: UpdateLanguageRequest = req.body; + + logger.info("언어 수정 요청", { langCode, languageData, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const updatedLanguage = await multiLangService.updateLanguage(langCode, { + ...languageData, + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 수정되었습니다.", + data: updatedLanguage, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("언어 수정 실패:", error); + res.status(500).json({ + success: false, + message: "언어 수정 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/languages/:langCode/toggle + * 언어 상태 토글 API + */ +export const toggleLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + logger.info("언어 상태 토글 요청", { langCode, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const result = await multiLangService.toggleLanguage(langCode); + + const response: ApiResponse = { + success: true, + message: `언어가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("언어 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "언어 상태 변경 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys + * 다국어 키 목록 조회 API + */ +export const getLangKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, menuCode, keyType, searchText } = req.query; + logger.info("다국어 키 목록 조회 요청", { + query: req.query, + user: req.user, + }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const langKeys = await multiLangService.getLangKeys({ + companyCode: companyCode as string, + menuCode: menuCode as string, + keyType: keyType as string, + searchText: searchText as string, + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키 목록 조회 성공", + data: langKeys, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 키 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 목록 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_KEYS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys/:keyId/texts + * 특정 키의 다국어 텍스트 조회 API + */ +export const getLangTexts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: "다국어 텍스트 조회 성공", + data: langTexts, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 텍스트 조회 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXTS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys + * 다국어 키 생성 API + */ +export const createLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const keyData: CreateLangKeyRequest = req.body; + logger.info("다국어 키 생성 요청", { keyData, user: req.user }); + + // 필수 입력값 검증 + if (!keyData.companyCode || !keyData.langKey) { + res.status(400).json({ + success: false, + message: "회사 코드와 언어 키는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode and langKey are required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const keyId = await multiLangService.createLangKey({ + ...keyData, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 생성 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/keys/:keyId + * 다국어 키 수정 API + */ +export const updateLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + const keyData: UpdateLangKeyRequest = req.body; + + logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + await multiLangService.updateLangKey(parseInt(keyId), { + ...keyData, + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 수정되었습니다.", + data: "수정 완료", + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 키 수정 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 수정 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * DELETE /api/multilang/keys/:keyId + * 다국어 키 삭제 API + */ +export const deleteLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + await multiLangService.deleteLangKey(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 삭제되었습니다.", + data: "삭제 완료", + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 키 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 삭제 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/keys/:keyId/toggle + * 다국어 키 상태 토글 API + */ +export const toggleLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const result = await multiLangService.toggleLangKey(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: `다국어 키가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 키 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 상태 변경 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/:keyId/texts + * 다국어 텍스트 저장/수정 API + */ +export const saveLangTexts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + const textData: SaveLangTextsRequest = req.body; + + logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); + + // 필수 입력값 검증 + if ( + !textData.texts || + !Array.isArray(textData.texts) || + textData.texts.length === 0 + ) { + res.status(400).json({ + success: false, + message: "텍스트 데이터는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "texts array is required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + await multiLangService.saveLangTexts(parseInt(keyId), { + texts: textData.texts.map((text) => ({ + ...text, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + })), + }); + + const response: ApiResponse = { + success: true, + message: "다국어 텍스트가 성공적으로 저장되었습니다.", + data: "저장 완료", + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("다국어 텍스트 저장 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 저장 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXTS_SAVE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/user-text/:companyCode/:menuCode/:langKey + * 사용자별 다국어 텍스트 조회 API + */ +export const getUserText = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, menuCode, langKey } = req.params; + const { userLang } = req.query; + + logger.info("사용자별 다국어 텍스트 조회 요청", { + companyCode, + menuCode, + langKey, + userLang, + user: req.user, + }); + + if (!userLang) { + res.status(400).json({ + success: false, + message: "사용자 언어는 필수입니다.", + error: { + code: "MISSING_USER_LANG", + details: "userLang query parameter is required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const langText = await multiLangService.getUserText({ + companyCode, + menuCode, + langKey, + userLang: userLang as string, + }); + + const response: ApiResponse = { + success: true, + message: "사용자별 다국어 텍스트 조회 성공", + data: langText, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("사용자별 다국어 텍스트 조회 실패:", error); + res.status(500).json({ + success: false, + message: "사용자별 다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "USER_TEXT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/text/:companyCode/:langKey/:langCode + * 특정 키의 다국어 텍스트 조회 API + */ +export const getLangText = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, langKey, langCode } = req.params; + + logger.info("특정 키의 다국어 텍스트 조회 요청", { + companyCode, + langKey, + langCode, + user: req.user, + }); + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + const multiLangService = new MultiLangService(client); + const langText = await multiLangService.getLangText( + companyCode, + langKey, + langCode + ); + + const response: ApiResponse = { + success: true, + message: "특정 키의 다국어 텍스트 조회 성공", + data: langText, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("특정 키의 다국어 텍스트 조회 실패:", error); + res.status(500).json({ + success: false, + message: "특정 키의 다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/batch + * 다국어 텍스트 배치 조회 API */ export const getBatchTranslations = async ( req: AuthenticatedRequest, @@ -22,7 +763,7 @@ export const getBatchTranslations = async ( ): Promise => { try { const { companyCode, menuCode, userLang } = req.query; - const { langKeys } = req.body; // 배열로 여러 키 전달 + const { langKeys } = req.body; logger.info("다국어 텍스트 배치 조회 요청", { companyCode, @@ -36,290 +777,63 @@ export const getBatchTranslations = async ( res.status(400).json({ success: false, message: "langKeys 배열이 필요합니다.", + error: { + code: "MISSING_LANG_KEYS", + details: "langKeys array is required", + }, }); 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, + if (!companyCode || !userLang) { + res.status(400).json({ + success: false, + message: "companyCode와 userLang은 필수입니다.", + error: { + code: "MISSING_REQUIRED_PARAMS", + details: "companyCode and userLang are required", + }, }); return; } - // 1. 모든 키에 대한 마스터 정보를 한번에 조회 - logger.info("다국어 키 마스터 배치 조회 시작", { - keyCount: langKeys.length, + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", }); - 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 - `; + await client.connect(); - logger.info("다국어 키 마스터 배치 조회 결과", { - requestedKeys: langKeys.length, - foundKeys: langKeyMasters.length, - }); - - if (langKeyMasters.length === 0) { - // 마스터 데이터가 없으면 기본값 반환 - const defaultTranslations = getDefaultTranslations( + try { + const multiLangService = new MultiLangService(client); + const translations = await multiLangService.getBatchTranslations({ + companyCode: companyCode as string, + menuCode: menuCode as string, + userLang: userLang as string, langKeys, - userLang as string - ); - - // 캐시에 저장 - translationCache.set(cacheKey, { - data: defaultTranslations, - timestamp: Date.now(), }); - res.status(200).json({ + const response: ApiResponse> = { success: true, - data: defaultTranslations, - message: "기본값으로 다국어 텍스트 조회 성공", - fromCache: false, - }); - return; + message: "다국어 텍스트 배치 조회 성공", + data: translations, + }; + + res.status(200).json(response); + } finally { + await client.end(); } - - // 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 }); + 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 (하위 호환성 유지) - */ -export const getUserText = async (req: AuthenticatedRequest, res: Response) => { - try { - const { companyCode, menuCode, langKey } = req.params; - const { userLang } = req.query; - - logger.info("단일 다국어 텍스트 조회 요청", { - companyCode, - menuCode, - langKey, - userLang, - user: req.user, - }); - - // 배치 API를 사용하여 단일 키 조회 - const batchResult = await getBatchTranslations( - { - ...req, - body: { langKeys: [langKey] }, - query: { companyCode, menuCode, userLang }, - } as any, - res - ); - - // 배치 API에서 이미 응답을 보냈으므로 여기서는 아무것도 하지 않음 - return; - } catch (error) { - logger.error("단일 다국어 텍스트 조회 실패", { error }); - res.status(500).json({ - success: false, - message: "다국어 텍스트 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } -}; - -/** - * 기본 번역 텍스트 반환 (개별 키) - */ -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", + error: { + code: "BATCH_TRANSLATION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index fc098728..1d40c7b9 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -19,17 +19,6 @@ import { deleteCompany, // 회사 삭제 getUserLocale, setUserLocale, - getLanguageList, - getLangKeyList, - getLangTextList, - saveLangTexts, - saveLangKey, - updateLangKey, - deleteLangKey, - toggleLangKeyStatus, - saveLanguage, - updateLanguage, - toggleLanguageStatus, } from "../controllers/adminController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -67,17 +56,4 @@ router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 router.get("/user-locale", getUserLocale); router.post("/user-locale", setUserLocale); -// 다국어 관리 API -router.get("/multilang/languages", getLanguageList); -router.get("/multilang/keys", getLangKeyList); -router.get("/multilang/keys/:keyId/texts", getLangTextList); -router.post("/multilang/keys/:keyId/texts", saveLangTexts); -router.post("/multilang/keys", saveLangKey); -router.put("/multilang/keys/:keyId", updateLangKey); -router.delete("/multilang/keys/:keyId", deleteLangKey); -router.put("/multilang/keys/:keyId/toggle", toggleLangKeyStatus); -router.post("/multilang/languages", saveLanguage); -router.put("/multilang/languages/:langCode", updateLanguage); -router.put("/multilang/languages/:langCode/toggle", toggleLanguageStatus); - export default router; diff --git a/backend-node/src/routes/multilangRoutes.ts b/backend-node/src/routes/multilangRoutes.ts index 30782c10..cf07b5bd 100644 --- a/backend-node/src/routes/multilangRoutes.ts +++ b/backend-node/src/routes/multilangRoutes.ts @@ -1,23 +1,50 @@ -import { Router } from "express"; -import { - getUserText, - getBatchTranslations, - clearCache, -} from "../controllers/multilangController"; +import express from "express"; import { authenticateToken } from "../middleware/authMiddleware"; +import { + // 언어 관리 API + getLanguages, + createLanguage, + updateLanguage, + toggleLanguage, -const router = Router(); + // 다국어 키 관리 API + getLangKeys, + getLangTexts, + createLangKey, + updateLangKey, + deleteLangKey, + toggleLangKey, -// 모든 multilang 라우트에 인증 미들웨어 적용 + // 다국어 텍스트 관리 API + saveLangTexts, + getUserText, + getLangText, + getBatchTranslations, +} from "../controllers/multilangController"; + +const router = express.Router(); + +// 모든 다국어 관리 라우트에 인증 미들웨어 적용 router.use(authenticateToken); -// 다국어 텍스트 API -router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); +// 언어 관리 API +router.get("/languages", getLanguages); // 언어 목록 조회 +router.post("/languages", createLanguage); // 언어 생성 +router.put("/languages/:langCode", updateLanguage); // 언어 수정 +router.put("/languages/:langCode/toggle", toggleLanguage); // 언어 상태 토글 -// 다국어 텍스트 배치 조회 API (새로운 방식) -router.post("/batch", getBatchTranslations); +// 다국어 키 관리 API +router.get("/keys", getLangKeys); // 다국어 키 목록 조회 +router.get("/keys/:keyId/texts", getLangTexts); // 특정 키의 다국어 텍스트 조회 +router.post("/keys", createLangKey); // 다국어 키 생성 +router.put("/keys/:keyId", updateLangKey); // 다국어 키 수정 +router.delete("/keys/:keyId", deleteLangKey); // 다국어 키 삭제 +router.put("/keys/:keyId/toggle", toggleLangKey); // 다국어 키 상태 토글 -// 캐시 초기화 API (개발/테스트용) -router.delete("/cache", clearCache); +// 다국어 텍스트 관리 API +router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/수정 +router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회 +router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회 +router.post("/batch", getBatchTranslations); // 다국어 텍스트 배치 조회 export default router; diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts new file mode 100644 index 00000000..480b9c60 --- /dev/null +++ b/backend-node/src/services/multilangService.ts @@ -0,0 +1,855 @@ +import { Client } from "pg"; +import { logger } from "../utils/logger"; +import { + Language, + LangKey, + LangText, + CreateLanguageRequest, + UpdateLanguageRequest, + CreateLangKeyRequest, + UpdateLangKeyRequest, + SaveLangTextsRequest, + GetLangKeysParams, + GetUserTextParams, + BatchTranslationRequest, + ApiResponse, +} from "../types/multilang"; + +export class MultiLangService { + private client: Client; + + constructor(client: Client) { + this.client = client; + } + + /** + * 언어 목록 조회 + */ + async getLanguages(): Promise { + try { + logger.info("언어 목록 조회 시작"); + + const query = ` + SELECT + lang_code as "langCode", + lang_name as "langName", + lang_native as "langNative", + is_active as "isActive", + sort_order as "sortOrder", + created_date as "createdDate", + created_by as "createdBy", + updated_date as "updatedDate", + updated_by as "updatedBy" + FROM language_master + ORDER BY sort_order, lang_code + `; + + const result = await this.client.query(query); + const languages = result.rows.map((row) => ({ + ...row, + createdDate: row.createdDate ? new Date(row.createdDate) : undefined, + updatedDate: row.updatedDate ? new Date(row.updatedDate) : undefined, + })); + + logger.info(`언어 목록 조회 완료: ${languages.length}개`); + return languages; + } catch (error) { + logger.error("언어 목록 조회 중 오류 발생:", error); + throw new Error( + `언어 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 언어 생성 + */ + async createLanguage(languageData: CreateLanguageRequest): Promise { + try { + logger.info("언어 생성 시작", { languageData }); + + // 중복 체크 + const duplicateCheckQuery = ` + SELECT lang_code FROM language_master WHERE lang_code = $1 + `; + const duplicateResult = await this.client.query(duplicateCheckQuery, [ + languageData.langCode, + ]); + + if (duplicateResult.rows.length > 0) { + throw new Error( + `이미 존재하는 언어 코드입니다: ${languageData.langCode}` + ); + } + + // 언어 생성 + const insertQuery = ` + INSERT INTO language_master ( + lang_code, lang_name, lang_native, is_active, sort_order, + created_date, created_by, updated_date, updated_by + ) VALUES ($1, $2, $3, $4, $5, now(), $6, now(), $7) + RETURNING * + `; + + const insertValues = [ + languageData.langCode, + languageData.langName, + languageData.langNative, + languageData.isActive || "Y", + languageData.sortOrder || 0, + languageData.createdBy || "system", + languageData.updatedBy || "system", + ]; + + const result = await this.client.query(insertQuery, insertValues); + const createdLanguage = result.rows[0]; + + logger.info("언어 생성 완료", { langCode: createdLanguage.lang_code }); + + return { + langCode: createdLanguage.lang_code, + langName: createdLanguage.lang_name, + langNative: createdLanguage.lang_native, + isActive: createdLanguage.is_active, + sortOrder: createdLanguage.sort_order, + createdDate: createdLanguage.created_date + ? new Date(createdLanguage.created_date) + : undefined, + createdBy: createdLanguage.created_by, + updatedDate: createdLanguage.updated_date + ? new Date(createdLanguage.updated_date) + : undefined, + updatedBy: createdLanguage.updated_by, + }; + } catch (error) { + logger.error("언어 생성 중 오류 발생:", error); + throw new Error( + `언어 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 언어 수정 + */ + async updateLanguage( + langCode: string, + languageData: UpdateLanguageRequest + ): Promise { + try { + logger.info("언어 수정 시작", { langCode, languageData }); + + // 기존 언어 확인 + const checkQuery = ` + SELECT * FROM language_master WHERE lang_code = $1 + `; + const checkResult = await this.client.query(checkQuery, [langCode]); + + if (checkResult.rows.length === 0) { + throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); + } + + // 언어 수정 + const updateQuery = ` + UPDATE language_master + SET + lang_name = COALESCE($2, lang_name), + lang_native = COALESCE($3, lang_native), + is_active = COALESCE($4, is_active), + sort_order = COALESCE($5, sort_order), + updated_date = now(), + updated_by = $6 + WHERE lang_code = $1 + RETURNING * + `; + + const updateValues = [ + langCode, + languageData.langName, + languageData.langNative, + languageData.isActive, + languageData.sortOrder, + languageData.updatedBy || "system", + ]; + + const result = await this.client.query(updateQuery, updateValues); + const updatedLanguage = result.rows[0]; + + logger.info("언어 수정 완료", { langCode }); + + return { + langCode: updatedLanguage.lang_code, + langName: updatedLanguage.lang_name, + langNative: updatedLanguage.lang_native, + isActive: updatedLanguage.is_active, + sortOrder: updatedLanguage.sort_order, + createdDate: updatedLanguage.created_date + ? new Date(updatedLanguage.created_date) + : undefined, + createdBy: updatedLanguage.created_by, + updatedDate: updatedLanguage.updated_date + ? new Date(updatedLanguage.updated_date) + : undefined, + updatedBy: updatedLanguage.updated_by, + }; + } catch (error) { + logger.error("언어 수정 중 오류 발생:", error); + throw new Error( + `언어 수정 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 언어 상태 토글 + */ + async toggleLanguage(langCode: string): Promise { + try { + logger.info("언어 상태 토글 시작", { langCode }); + + // 현재 상태 조회 + const currentQuery = ` + SELECT is_active FROM language_master WHERE lang_code = $1 + `; + const currentResult = await this.client.query(currentQuery, [langCode]); + + if (currentResult.rows.length === 0) { + throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); + } + + const currentStatus = currentResult.rows[0].is_active; + const newStatus = currentStatus === "Y" ? "N" : "Y"; + + // 상태 업데이트 + const updateQuery = ` + UPDATE language_master + SET is_active = $2, updated_date = now(), updated_by = 'system' + WHERE lang_code = $1 + `; + + await this.client.query(updateQuery, [langCode, newStatus]); + + const result = newStatus === "Y" ? "활성화" : "비활성화"; + logger.info("언어 상태 토글 완료", { langCode, result }); + + return result; + } catch (error) { + logger.error("언어 상태 토글 중 오류 발생:", error); + throw new Error( + `언어 상태 토글 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 다국어 키 목록 조회 + */ + async getLangKeys(params: GetLangKeysParams): Promise { + try { + logger.info("다국어 키 목록 조회 시작", { params }); + + let query = ` + SELECT + key_id as "keyId", + company_code as "companyCode", + menu_name as "menuName", + lang_key as "langKey", + description, + is_active as "isActive", + created_date as "createdDate", + created_by as "createdBy", + updated_date as "updatedDate", + updated_by as "updatedBy" + FROM multi_lang_key_master + WHERE 1=1 + `; + + const queryParams: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 + if (params.companyCode) { + query += ` AND company_code = $${paramIndex}`; + queryParams.push(params.companyCode); + paramIndex++; + } + + // 메뉴 코드 필터 + if (params.menuCode) { + query += ` AND menu_name = $${paramIndex}`; + queryParams.push(params.menuCode); + paramIndex++; + } + + // 검색 조건 + if (params.searchText) { + query += ` AND ( + lang_key ILIKE $${paramIndex} OR + description ILIKE $${paramIndex} OR + menu_name ILIKE $${paramIndex} + )`; + queryParams.push(`%${params.searchText}%`); + paramIndex++; + } + + // 정렬 + query += ` ORDER BY company_code, menu_name, lang_key`; + + const result = await this.client.query(query, queryParams); + const langKeys = result.rows.map((row) => ({ + ...row, + createdDate: row.createdDate ? new Date(row.createdDate) : undefined, + updatedDate: row.updatedDate ? new Date(row.updatedDate) : undefined, + })); + + logger.info(`다국어 키 목록 조회 완료: ${langKeys.length}개`); + return langKeys; + } catch (error) { + logger.error("다국어 키 목록 조회 중 오류 발생:", error); + throw new Error( + `다국어 키 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 특정 키의 다국어 텍스트 조회 + */ + async getLangTexts(keyId: number): Promise { + try { + logger.info("다국어 텍스트 조회 시작", { keyId }); + + const query = ` + SELECT + text_id as "textId", + key_id as "keyId", + lang_code as "langCode", + lang_text as "langText", + is_active as "isActive", + created_date as "createdDate", + created_by as "createdBy", + updated_date as "updatedDate", + updated_by as "updatedBy" + FROM multi_lang_text + WHERE key_id = $1 AND is_active = 'Y' + ORDER BY lang_code + `; + + const result = await this.client.query(query, [keyId]); + const langTexts = result.rows.map((row) => ({ + ...row, + createdDate: row.createdDate ? new Date(row.createdDate) : undefined, + updatedDate: row.updatedDate ? new Date(row.updatedDate) : undefined, + })); + + logger.info(`다국어 텍스트 조회 완료: ${langTexts.length}개`); + return langTexts; + } catch (error) { + logger.error("다국어 텍스트 조회 중 오류 발생:", error); + throw new Error( + `다국어 텍스트 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 다국어 키 생성 + */ + async createLangKey(keyData: CreateLangKeyRequest): Promise { + try { + logger.info("다국어 키 생성 시작", { keyData }); + + // 중복 체크 + const duplicateCheckQuery = ` + SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2 + `; + const duplicateResult = await this.client.query(duplicateCheckQuery, [ + keyData.companyCode, + keyData.langKey, + ]); + + if (duplicateResult.rows.length > 0) { + throw new Error( + `동일한 회사에 이미 존재하는 언어키입니다: ${keyData.langKey}` + ); + } + + // 다국어 키 생성 + const insertQuery = ` + INSERT INTO multi_lang_key_master ( + company_code, menu_name, lang_key, description, is_active, + created_date, created_by, updated_date, updated_by + ) VALUES ($1, $2, $3, $4, $5, now(), $6, now(), $7) + RETURNING key_id + `; + + const insertValues = [ + keyData.companyCode, + keyData.menuName || null, + keyData.langKey, + keyData.description || null, + keyData.isActive || "Y", + keyData.createdBy || "system", + keyData.updatedBy || "system", + ]; + + const result = await this.client.query(insertQuery, insertValues); + const keyId = result.rows[0].key_id; + + logger.info("다국어 키 생성 완료", { keyId, langKey: keyData.langKey }); + + return keyId; + } catch (error) { + logger.error("다국어 키 생성 중 오류 발생:", error); + throw new Error( + `다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 다국어 키 수정 + */ + async updateLangKey( + keyId: number, + keyData: UpdateLangKeyRequest + ): Promise { + try { + logger.info("다국어 키 수정 시작", { keyId, keyData }); + + // 기존 키 확인 + const checkQuery = ` + SELECT key_id FROM multi_lang_key_master WHERE key_id = $1 + `; + const checkResult = await this.client.query(checkQuery, [keyId]); + + if (checkResult.rows.length === 0) { + throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); + } + + // 중복 체크 (자신을 제외하고) + if (keyData.companyCode && keyData.langKey) { + const duplicateCheckQuery = ` + SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2 AND key_id != $3 + `; + const duplicateResult = await this.client.query(duplicateCheckQuery, [ + keyData.companyCode, + keyData.langKey, + keyId, + ]); + + if (duplicateResult.rows.length > 0) { + throw new Error( + `동일한 회사에 이미 존재하는 언어키입니다: ${keyData.langKey}` + ); + } + } + + // 다국어 키 수정 + const updateQuery = ` + UPDATE multi_lang_key_master + SET + company_code = COALESCE($2, company_code), + menu_name = COALESCE($3, menu_name), + lang_key = COALESCE($4, lang_key), + description = COALESCE($5, description), + updated_date = now(), + updated_by = $6 + WHERE key_id = $1 + `; + + const updateValues = [ + keyId, + keyData.companyCode, + keyData.menuName, + keyData.langKey, + keyData.description, + keyData.updatedBy || "system", + ]; + + await this.client.query(updateQuery, updateValues); + + logger.info("다국어 키 수정 완료", { keyId }); + } catch (error) { + logger.error("다국어 키 수정 중 오류 발생:", error); + throw new Error( + `다국어 키 수정 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 다국어 키 삭제 + */ + async deleteLangKey(keyId: number): Promise { + try { + logger.info("다국어 키 삭제 시작", { keyId }); + + // 기존 키 확인 + const checkQuery = ` + SELECT key_id FROM multi_lang_key_master WHERE key_id = $1 + `; + const checkResult = await this.client.query(checkQuery, [keyId]); + + if (checkResult.rows.length === 0) { + throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); + } + + // 트랜잭션 시작 + await this.client.query("BEGIN"); + + try { + // 관련된 다국어 텍스트 삭제 + const deleteTextsQuery = ` + DELETE FROM multi_lang_text WHERE key_id = $1 + `; + await this.client.query(deleteTextsQuery, [keyId]); + + // 다국어 키 삭제 + const deleteKeyQuery = ` + DELETE FROM multi_lang_key_master WHERE key_id = $1 + `; + await this.client.query(deleteKeyQuery, [keyId]); + + // 트랜잭션 커밋 + await this.client.query("COMMIT"); + + logger.info("다국어 키 삭제 완료", { keyId }); + } catch (error) { + // 트랜잭션 롤백 + await this.client.query("ROLLBACK"); + throw error; + } + } catch (error) { + logger.error("다국어 키 삭제 중 오류 발생:", error); + throw new Error( + `다국어 키 삭제 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 다국어 키 상태 토글 + */ + async toggleLangKey(keyId: number): Promise { + try { + logger.info("다국어 키 상태 토글 시작", { keyId }); + + // 현재 상태 조회 + const currentQuery = ` + SELECT is_active FROM multi_lang_key_master WHERE key_id = $1 + `; + const currentResult = await this.client.query(currentQuery, [keyId]); + + if (currentResult.rows.length === 0) { + throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); + } + + const currentStatus = currentResult.rows[0].is_active; + const newStatus = currentStatus === "Y" ? "N" : "Y"; + + // 상태 업데이트 + const updateQuery = ` + UPDATE multi_lang_key_master + SET is_active = $2, updated_date = now(), updated_by = 'system' + WHERE key_id = $1 + `; + + await this.client.query(updateQuery, [keyId, newStatus]); + + const result = newStatus === "Y" ? "활성화" : "비활성화"; + logger.info("다국어 키 상태 토글 완료", { keyId, result }); + + return result; + } catch (error) { + logger.error("다국어 키 상태 토글 중 오류 발생:", error); + throw new Error( + `다국어 키 상태 토글 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 다국어 텍스트 저장/수정 + */ + async saveLangTexts( + keyId: number, + textData: SaveLangTextsRequest + ): Promise { + try { + logger.info("다국어 텍스트 저장 시작", { + keyId, + textCount: textData.texts.length, + }); + + // 기존 키 확인 + const checkQuery = ` + SELECT key_id FROM multi_lang_key_master WHERE key_id = $1 + `; + const checkResult = await this.client.query(checkQuery, [keyId]); + + if (checkResult.rows.length === 0) { + throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); + } + + // 트랜잭션 시작 + await this.client.query("BEGIN"); + + try { + // 기존 텍스트 삭제 + const deleteTextsQuery = ` + DELETE FROM multi_lang_text WHERE key_id = $1 + `; + await this.client.query(deleteTextsQuery, [keyId]); + + // 새로운 텍스트 삽입 + for (const text of textData.texts) { + const insertTextQuery = ` + INSERT INTO multi_lang_text ( + key_id, lang_code, lang_text, is_active, + created_date, created_by, updated_date, updated_by + ) VALUES ($1, $2, $3, $4, now(), $5, now(), $6) + `; + + const insertValues = [ + keyId, + text.langCode, + text.langText, + text.isActive || "Y", + text.createdBy || "system", + text.updatedBy || "system", + ]; + + await this.client.query(insertTextQuery, insertValues); + } + + // 트랜잭션 커밋 + await this.client.query("COMMIT"); + + logger.info("다국어 텍스트 저장 완료", { + keyId, + savedCount: textData.texts.length, + }); + } catch (error) { + // 트랜잭션 롤백 + await this.client.query("ROLLBACK"); + throw error; + } + } catch (error) { + logger.error("다국어 텍스트 저장 중 오류 발생:", error); + throw new Error( + `다국어 텍스트 저장 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 사용자별 다국어 텍스트 조회 + */ + async getUserText(params: GetUserTextParams): Promise { + try { + logger.info("사용자별 다국어 텍스트 조회 시작", { params }); + + const query = ` + SELECT t.lang_text as "langText" + FROM multi_lang_key_master km + JOIN multi_lang_text t ON km.key_id = t.key_id + WHERE km.company_code = $1 + AND km.menu_name = $2 + AND km.lang_key = $3 + AND t.lang_code = $4 + AND km.is_active = 'Y' + AND t.is_active = 'Y' + LIMIT 1 + `; + + const result = await this.client.query(query, [ + params.companyCode, + params.menuCode, + params.langKey, + params.userLang, + ]); + + if (result.rows.length === 0) { + logger.warn("사용자별 다국어 텍스트를 찾을 수 없음", { params }); + return params.langKey; // 기본값으로 키 반환 + } + + const langText = result.rows[0].langText; + logger.info("사용자별 다국어 텍스트 조회 완료", { params, langText }); + + return langText; + } catch (error) { + logger.error("사용자별 다국어 텍스트 조회 중 오류 발생:", error); + throw new Error( + `사용자별 다국어 텍스트 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 특정 키의 다국어 텍스트 조회 + */ + async getLangText( + companyCode: string, + langKey: string, + langCode: string + ): Promise { + try { + logger.info("특정 키의 다국어 텍스트 조회 시작", { + companyCode, + langKey, + langCode, + }); + + const query = ` + SELECT t.lang_text as "langText" + FROM multi_lang_text t + JOIN multi_lang_key_master k ON t.key_id = k.key_id + WHERE k.company_code = $1 + AND k.lang_key = $2 + AND t.lang_code = $3 + AND t.is_active = 'Y' + AND k.is_active = 'Y' + `; + + const result = await this.client.query(query, [ + companyCode, + langKey, + langCode, + ]); + + if (result.rows.length === 0) { + logger.warn("특정 키의 다국어 텍스트를 찾을 수 없음", { + companyCode, + langKey, + langCode, + }); + return langKey; // 기본값으로 키 반환 + } + + const langText = result.rows[0].langText; + logger.info("특정 키의 다국어 텍스트 조회 완료", { + companyCode, + langKey, + langCode, + langText, + }); + + return langText; + } catch (error) { + logger.error("특정 키의 다국어 텍스트 조회 중 오류 발생:", error); + throw new Error( + `특정 키의 다국어 텍스트 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 배치 번역 조회 + */ + async getBatchTranslations( + params: BatchTranslationRequest + ): Promise> { + try { + logger.info("배치 번역 조회 시작", { + companyCode: params.companyCode, + menuCode: params.menuCode, + userLang: params.userLang, + keyCount: params.langKeys.length, + }); + + if (params.langKeys.length === 0) { + return {}; + } + + // 모든 키에 대한 마스터 정보를 한번에 조회 + const langKeyMastersQuery = ` + SELECT key_id, lang_key, company_code + FROM multi_lang_key_master + WHERE lang_key = ANY($1::varchar[]) + AND (company_code = $2::varchar OR company_code = '*') + ORDER BY + CASE WHEN company_code = $2::varchar THEN 1 ELSE 2 END, + lang_key, + company_code + `; + + const langKeyMasters = await this.client.query(langKeyMastersQuery, [ + params.langKeys, + params.companyCode, + ]); + + if (langKeyMasters.rows.length === 0) { + logger.warn("배치 번역: 언어키 마스터를 찾을 수 없음", { params }); + return this.createDefaultTranslations(params.langKeys); + } + + // 찾은 키들의 ID 목록 + const keyIds = langKeyMasters.rows.map((row) => row.key_id); + const foundKeys = langKeyMasters.rows.map((row) => row.lang_key); + + // 누락된 키들 (기본값으로 설정) + const missingKeys = params.langKeys.filter( + (key) => !foundKeys.includes(key) + ); + const result: Record = {}; + + // 기본값으로 누락된 키들 설정 + missingKeys.forEach((key) => { + result[key] = key; + }); + + // 실제 번역 텍스트 조회 + if (keyIds.length > 0) { + const textsQuery = ` + SELECT t.key_id, t.lang_text, km.lang_key + FROM multi_lang_text t + JOIN multi_lang_key_master km ON t.key_id = km.key_id + WHERE t.key_id = ANY($1::int[]) + AND t.lang_code = $2 + AND t.is_active = 'Y' + `; + + const texts = await this.client.query(textsQuery, [ + keyIds, + params.userLang, + ]); + + // 결과 매핑 + texts.rows.forEach((row) => { + result[row.lang_key] = row.lang_text; + }); + } + + logger.info("배치 번역 조회 완료", { + totalKeys: params.langKeys.length, + foundKeys: foundKeys.length, + missingKeys: missingKeys.length, + resultKeys: Object.keys(result).length, + }); + + return result; + } catch (error) { + logger.error("배치 번역 조회 중 오류 발생:", error); + throw new Error( + `배치 번역 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 기본 번역 생성 (키를 그대로 반환) + */ + private createDefaultTranslations( + langKeys: string[] + ): Record { + const result: Record = {}; + langKeys.forEach((key) => { + result[key] = key; + }); + return result; + } +} diff --git a/backend-node/src/types/multilang.ts b/backend-node/src/types/multilang.ts new file mode 100644 index 00000000..8ad8adb6 --- /dev/null +++ b/backend-node/src/types/multilang.ts @@ -0,0 +1,130 @@ +export interface Language { + langCode: string; + langName: string; + langNative: string; + isActive: string; + sortOrder?: number; + createdDate?: Date; + createdBy?: string; + updatedDate?: Date; + updatedBy?: string; +} + +export interface LangKey { + keyId?: number; + companyCode: string; + menuName?: string; + langKey: string; + description?: string; + isActive: string; + createdDate?: Date; + createdBy?: string; + updatedDate?: Date; + updatedBy?: string; +} + +export interface LangText { + textId?: number; + keyId: number; + langCode: string; + langText: string; + isActive: string; + createdDate?: Date; + createdBy?: string; + updatedDate?: Date; + updatedBy?: string; +} + +export interface LangKeyWithTexts extends LangKey { + texts: LangText[]; +} + +export interface CreateLanguageRequest { + langCode: string; + langName: string; + langNative: string; + isActive?: string; + sortOrder?: number; + createdBy?: string; + updatedBy?: string; +} + +export interface UpdateLanguageRequest { + langName?: string; + langNative?: string; + isActive?: string; + sortOrder?: number; + updatedBy?: string; +} + +export interface CreateLangKeyRequest { + companyCode: string; + menuName?: string; + langKey: string; + description?: string; + isActive?: string; + createdBy?: string; + updatedBy?: string; +} + +export interface UpdateLangKeyRequest { + companyCode?: string; + menuName?: string; + langKey?: string; + description?: string; + updatedBy?: string; +} + +export interface SaveLangTextsRequest { + texts: Array<{ + langCode: string; + langText: string; + isActive?: string; + createdBy?: string; + updatedBy?: string; + }>; +} + +export interface GetLangKeysParams { + companyCode?: string; + menuCode?: string; + keyType?: string; + searchText?: string; + page?: number; + limit?: number; +} + +export interface GetUserTextParams { + companyCode: string; + menuCode: string; + langKey: string; + userLang: string; +} + +export interface BatchTranslationRequest { + companyCode: string; + menuCode?: string; + userLang: string; + langKeys: string[]; +} + +export interface TranslationCacheEntry { + data: Record; + timestamp: number; +} + +export interface ApiResponse { + success: boolean; + message?: string; + data?: T; + error?: { + code: string; + details?: any; + }; + pagination?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} diff --git a/frontend/components/admin/MultiLang.tsx b/frontend/components/admin/MultiLang.tsx index aefe6a37..db587e6a 100644 --- a/frontend/components/admin/MultiLang.tsx +++ b/frontend/components/admin/MultiLang.tsx @@ -65,7 +65,7 @@ export default function MultiLangPage() { const fetchCompanies = async () => { try { console.log("회사 목록 조회 시작"); - const response = await apiClient.get("/api/admin/companies"); + const response = await apiClient.get("/admin/companies"); console.log("회사 목록 응답 데이터:", response.data); const data = response.data; @@ -87,7 +87,7 @@ export default function MultiLangPage() { // 언어 목록 조회 const fetchLanguages = async () => { try { - const response = await apiClient.get("/api/admin/multilang/languages"); + const response = await apiClient.get("/multilang/languages"); const data = response.data; if (data.success) { setLanguages(data.data); @@ -100,7 +100,7 @@ export default function MultiLangPage() { // 다국어 키 목록 조회 const fetchLangKeys = async () => { try { - const response = await apiClient.get("/api/admin/multilang/keys"); + const response = await apiClient.get("/multilang/keys"); const data = response.data; if (data.success) { console.log("✅ 전체 키 목록 로드:", data.data.length, "개"); @@ -147,7 +147,7 @@ export default function MultiLangPage() { const fetchLangTexts = async (keyId: number) => { try { console.log("다국어 텍스트 조회 시작: keyId =", keyId); - const response = await apiClient.get(`/api/admin/multilang/keys/${keyId}/texts`); + const response = await apiClient.get(`/multilang/keys/${keyId}/texts`); const data = response.data; console.log("다국어 텍스트 조회 응답:", data); if (data.success) { @@ -203,7 +203,18 @@ export default function MultiLangPage() { if (!selectedKey) return; try { - const response = await apiClient.post(`/api/admin/multilang/keys/${selectedKey.keyId}/texts`, editingTexts); + // 백엔드가 기대하는 형식으로 데이터 변환 + const requestData = { + texts: editingTexts.map((text) => ({ + langCode: text.langCode, + langText: text.langText, + isActive: text.isActive || "Y", + createdBy: user?.userId || "system", + updatedBy: user?.userId || "system", + })), + }; + + const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData); const data = response.data; if (data.success) { alert("저장되었습니다."); @@ -245,9 +256,9 @@ export default function MultiLangPage() { let response; if (editingLanguage) { - response = await apiClient.put(`/api/admin/multilang/languages/${editingLanguage.langCode}`, requestData); + response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData); } else { - response = await apiClient.post("/api/admin/multilang/languages", requestData); + response = await apiClient.post("/multilang/languages", requestData); } const result = response.data; @@ -282,17 +293,11 @@ export default function MultiLangPage() { try { const deletePromises = Array.from(selectedLanguages).map((langCode) => - fetch(`${API_BASE_URL}/multilang/languages/${langCode}`, { - method: "DELETE", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - }), + apiClient.delete(`/admin/multilang/languages/${langCode}`), ); const responses = await Promise.all(deletePromises); - const failedDeletes = responses.filter((response) => !response.ok); + const failedDeletes = responses.filter((response) => !response.data.success); if (failedDeletes.length === 0) { alert("선택된 언어가 삭제되었습니다."); @@ -344,9 +349,9 @@ export default function MultiLangPage() { let response; if (editingKey) { - response = await apiClient.put(`/api/admin/multilang/keys/${editingKey.keyId}`, requestData); + response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData); } else { - response = await apiClient.post("/api/admin/multilang/keys", requestData); + response = await apiClient.post("/multilang/keys", requestData); } const data = response.data; @@ -383,7 +388,7 @@ export default function MultiLangPage() { // 키 상태 토글 const handleToggleStatus = async (keyId: number) => { try { - const response = await apiClient.put(`/api/admin/multilang/keys/${keyId}/toggle`); + const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`); const data = response.data; if (data.success) { alert(`키가 ${data.data}되었습니다.`); @@ -400,7 +405,7 @@ export default function MultiLangPage() { // 언어 상태 토글 const handleToggleLanguageStatus = async (langCode: string) => { try { - const response = await apiClient.put(`/api/admin/multilang/languages/${langCode}/toggle`); + const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`); const data = response.data; if (data.success) { alert(`언어가 ${data.data}되었습니다.`); @@ -440,9 +445,7 @@ export default function MultiLangPage() { } try { - const deletePromises = Array.from(selectedKeys).map((keyId) => - apiClient.delete(`/api/admin/multilang/keys/${keyId}`), - ); + const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`)); const responses = await Promise.all(deletePromises); const allSuccess = responses.every((response) => response.data.success); @@ -472,7 +475,7 @@ export default function MultiLangPage() { } try { - const response = await apiClient.delete(`/api/admin/multilang/keys/${keyId}`); + const response = await apiClient.delete(`/multilang/keys/${keyId}`); const data = response.data; if (data.success) { alert("언어 키가 영구적으로 삭제되었습니다."); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index f126df67..d4ef4206 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"; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api"; // JWT 토큰 관리 유틸리티 const TokenManager = { -- 2.43.0 From 070fc7d4443f9676cfa96ef00dcca093d63d7e06 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 25 Aug 2025 17:07:29 +0900 Subject: [PATCH 003/255] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20config=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=93=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - backend-node/src/config/database.ts | 45 ++++++++++ backend-node/src/config/environment.ts | 117 +++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/config/database.ts create mode 100644 backend-node/src/config/environment.ts diff --git a/.gitignore b/.gitignore index 380a5719..26fdcbad 100644 --- a/.gitignore +++ b/.gitignore @@ -190,7 +190,6 @@ docker-compose.prod.yml .env.docker # 설정 파일들 -config/ configs/ settings/ *.config.js diff --git a/backend-node/src/config/database.ts b/backend-node/src/config/database.ts new file mode 100644 index 00000000..2d13d368 --- /dev/null +++ b/backend-node/src/config/database.ts @@ -0,0 +1,45 @@ +import { PrismaClient } from "@prisma/client"; +import config from "./environment"; + +// Prisma 클라이언트 인스턴스 생성 +const prisma = new PrismaClient({ + datasources: { + db: { + url: config.databaseUrl, + }, + }, + log: config.debug ? ["query", "info", "warn", "error"] : ["error"], +}); + +// 데이터베이스 연결 테스트 +async function testConnection() { + try { + await prisma.$connect(); + console.log("✅ 데이터베이스 연결 성공"); + } catch (error) { + console.error("❌ 데이터베이스 연결 실패:", error); + process.exit(1); + } +} + +// 애플리케이션 종료 시 연결 해제 +process.on("beforeExit", async () => { + await prisma.$disconnect(); +}); + +process.on("SIGINT", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +// 초기 연결 테스트 (개발 환경에서만) +if (config.nodeEnv === "development") { + testConnection(); +} + +export default prisma; diff --git a/backend-node/src/config/environment.ts b/backend-node/src/config/environment.ts new file mode 100644 index 00000000..be936f76 --- /dev/null +++ b/backend-node/src/config/environment.ts @@ -0,0 +1,117 @@ +import dotenv from "dotenv"; +import path from "path"; + +// .env 파일 로드 +dotenv.config({ path: path.resolve(process.cwd(), ".env") }); + +interface Config { + // 서버 설정 + port: number; + host: string; + nodeEnv: string; + + // 데이터베이스 설정 + databaseUrl: string; + + // JWT 설정 + jwt: { + secret: string; + expiresIn: string; + refreshExpiresIn: string; + }; + + // 보안 설정 + bcryptRounds: number; + sessionSecret: string; + + // CORS 설정 + cors: { + origin: string; + credentials: boolean; + }; + + // 로깅 설정 + logging: { + level: string; + file: string; + }; + + // API 설정 + apiPrefix: string; + apiVersion: string; + + // 파일 업로드 설정 + maxFileSize: number; + uploadDir: string; + + // 이메일 설정 + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpPass: string; + + // Redis 설정 + redisUrl: string; + + // 개발 환경 설정 + debug: boolean; + showErrorDetails: boolean; +} + +const config: Config = { + // 서버 설정 + port: parseInt(process.env.PORT || "3000", 10), + host: process.env.HOST || "0.0.0.0", + nodeEnv: process.env.NODE_ENV || "development", + + // 데이터베이스 설정 + databaseUrl: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + + // JWT 설정 + jwt: { + secret: process.env.JWT_SECRET || "ilshin-plm-super-secret-jwt-key-2024", + expiresIn: process.env.JWT_EXPIRES_IN || "24h", + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d", + }, + + // 보안 설정 + bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || "12", 10), + sessionSecret: process.env.SESSION_SECRET || "ilshin-plm-session-secret-2024", + + // CORS 설정 + cors: { + origin: process.env.CORS_ORIGIN || "http://localhost:9771", + credentials: process.env.CORS_CREDENTIALS === "true", + }, + + // 로깅 설정 + logging: { + level: process.env.LOG_LEVEL || "info", + file: process.env.LOG_FILE || "logs/app.log", + }, + + // API 설정 + apiPrefix: process.env.API_PREFIX || "/api", + apiVersion: process.env.API_VERSION || "v1", + + // 파일 업로드 설정 + maxFileSize: parseInt(process.env.MAX_FILE_SIZE || "10485760", 10), + uploadDir: process.env.UPLOAD_DIR || "uploads", + + // 이메일 설정 + smtpHost: process.env.SMTP_HOST || "smtp.gmail.com", + smtpPort: parseInt(process.env.SMTP_PORT || "587", 10), + smtpUser: process.env.SMTP_USER || "", + smtpPass: process.env.SMTP_PASS || "", + + // Redis 설정 + redisUrl: process.env.REDIS_URL || "redis://localhost:6379", + + // 개발 환경 설정 + debug: process.env.DEBUG === "true", + showErrorDetails: process.env.SHOW_ERROR_DETAILS === "true", +}; + +export default config; -- 2.43.0 From 307faba089ae84e252116bd3e7d682261b53ee59 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 25 Aug 2025 17:22:20 +0900 Subject: [PATCH 004/255] =?UTF-8?q?=EB=A9=94=EB=89=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=A4=91=EA=B0=84=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/multilangController.ts | 26 +- frontend/app/(main)/admin/layout.tsx | 71 ++- frontend/components/admin/MenuFormModal.tsx | 86 ++- frontend/components/admin/MenuManagement.tsx | 498 +++++++++--------- frontend/hooks/useAuth.ts | 41 ++ frontend/hooks/useMultiLang.ts | 114 ++-- frontend/lib/api/client.ts | 6 +- frontend/lib/api/menu.ts | 4 +- frontend/lib/utils/multilang.ts | 4 +- 9 files changed, 485 insertions(+), 365 deletions(-) diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 738d486d..ec5fa949 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -763,12 +763,22 @@ export const getBatchTranslations = async ( ): Promise => { try { const { companyCode, menuCode, userLang } = req.query; - const { langKeys } = req.body; + const { + langKeys, + companyCode: bodyCompanyCode, + menuCode: bodyMenuCode, + userLang: bodyUserLang, + } = req.body; + + // query params에서 읽지 못한 경우 body에서 읽기 + const finalCompanyCode = companyCode || bodyCompanyCode; + const finalMenuCode = menuCode || bodyMenuCode; + const finalUserLang = userLang || bodyUserLang; logger.info("다국어 텍스트 배치 조회 요청", { - companyCode, - menuCode, - userLang, + companyCode: finalCompanyCode, + menuCode: finalMenuCode, + userLang: finalUserLang, keyCount: langKeys?.length || 0, user: req.user, }); @@ -785,7 +795,7 @@ export const getBatchTranslations = async ( return; } - if (!companyCode || !userLang) { + if (!finalCompanyCode || !finalUserLang) { res.status(400).json({ success: false, message: "companyCode와 userLang은 필수입니다.", @@ -809,9 +819,9 @@ export const getBatchTranslations = async ( try { const multiLangService = new MultiLangService(client); const translations = await multiLangService.getBatchTranslations({ - companyCode: companyCode as string, - menuCode: menuCode as string, - userLang: userLang as string, + companyCode: finalCompanyCode as string, + menuCode: finalMenuCode as string, + userLang: finalUserLang as string, langKeys, }); diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx index 89738fb2..84882137 100644 --- a/frontend/app/(main)/admin/layout.tsx +++ b/frontend/app/(main)/admin/layout.tsx @@ -313,17 +313,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) } }, [userLang]); - // 컴포넌트 마운트 시 강제로 번역 로드 (userLang이 아직 설정되지 않았을 수 있음) + // 컴포넌트 마운트 시 userLang이 설정될 때까지 대기 useEffect(() => { - const timer = setTimeout(() => { - if (!userLang) { - console.log("🔄 Admin Layout 마운트 후 강제 번역 로드 (userLang 없음)"); - loadTranslations(); - } - }, 100); // 100ms 후 실행 - - return () => clearTimeout(timer); - }, []); // 컴포넌트 마운트 시 한 번만 실행 + if (userLang) { + console.log("🔄 userLang 설정됨, 번역 로드 시작:", userLang); + loadTranslations(); + } + }, [userLang]); // userLang이 설정될 때마다 실행 // 키보드 단축키로 사이드바 토글 useEffect(() => { @@ -359,11 +355,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const loadTranslations = async () => { try { - // 현재 사용자 언어 사용 - const currentUserLang = userLang || "en"; + // userLang이 설정되지 않았으면 번역 로드하지 않음 + if (!userLang) { + console.log("⏳ userLang이 설정되지 않음, 번역 로드 대기"); + return; + } + console.log("🌐 Admin Layout 번역 로드 시작", { userLang, - currentUserLang, }); // API 직접 호출로 현재 언어 사용 (배치 조회 방식) @@ -380,7 +379,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) params: { companyCode, menuCode: "MENU_MANAGEMENT", - userLang: currentUserLang, + userLang: userLang, }, }, ); @@ -392,24 +391,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다."; // 번역 캐시에 저장 - setTranslationCache(currentUserLang, translations); + setTranslationCache(userLang, translations); // 상태 업데이트 setMenuTranslations({ title, description }); - console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang: currentUserLang }); + console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang }); } else { - // 기본값 사용 - const title = "메뉴 관리"; - const description = "시스템의 메뉴 구조와 권한을 관리합니다."; + // 전역 사용자 로케일 확인하여 기본값 설정 + const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR"; + console.log("🌐 전역 사용자 로케일 확인:", globalUserLang); + + // 사용자 로케일에 따른 기본값 설정 + let title, description; + if (globalUserLang === "US") { + title = "Menu Management"; + description = "Manage system menu structure and permissions"; + } else { + title = "메뉴 관리"; + description = "시스템의 메뉴 구조와 권한을 관리합니다."; + } + setMenuTranslations({ title, description }); - console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: currentUserLang }); + console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: globalUserLang }); } } catch (error) { console.error("❌ Admin Layout 배치 번역 로드 실패:", error); - // 오류 시 기본값 사용 - const title = "메뉴 관리"; - const description = "시스템의 메뉴 구조와 권한을 관리합니다."; + // 오류 시에도 전역 사용자 로케일 확인하여 기본값 설정 + const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR"; + console.log("🌐 오류 시 전역 사용자 로케일 확인:", globalUserLang); + + let title, description; + if (globalUserLang === "US") { + title = "Menu Management"; + description = "Manage system menu structure and permissions"; + } else { + title = "메뉴 관리"; + description = "시스템의 메뉴 구조와 권한을 관리합니다."; + } + setMenuTranslations({ title, description }); } } catch (error) { @@ -510,11 +530,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })

인증 실패

토큰이 없습니다. 3초 후 로그인 페이지로 이동합니다.

-
-

디버깅 정보

-

현재 경로: {pathname}

-

토큰: {localStorage.getItem("authToken") ? "존재" : "없음"}

-
)} diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index 3770a095..a2eb5ceb 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -345,58 +345,79 @@ export const MenuFormModal: React.FC = ({ const selectedLangKeyInfo = getSelectedLangKeyInfo(); + // 전역 사용자 로케일 가져오기 + const getCurrentUserLang = () => { + return (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR"; + }; + return ( {isEdit - ? getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE) - : getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)} + ? getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE, getCurrentUserLang()) + : getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE, getCurrentUserLang())}
- +
- +
- + {!isEdit && level !== 1 && ( -

{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}

+

+ {getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE, getCurrentUserLang())} +

)}
- +
- + handleInputChange("menuNameKor", e.target.value)} - placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)} + placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER, getCurrentUserLang())} required />
- + handleInputChange("menuUrl", e.target.value)} - placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)} + placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER, getCurrentUserLang())} />
- + +
+ +
+ + +
+ +
+
매핑정보(계약테이블)
+
+ + ----------> + +
+
+ + ----------> + +
+
+ + ----------> + +
+
+ +
+
매핑정보(프로젝트테이블)
+
+ + ----------> + +
+
+ 자동생성 + ----------> + +
+
+ + ----------> + +
+
+ + ----------> + +
+
+ +
+ +
+ + 개 항목을 + + 개로 분할하여 + + + + 건 저장 +
+
+
+ + +
+
🌐 외부 호출 상세 설정
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + + + + + + \ No newline at end of file -- 2.43.0 From 7c21c2eba6c2fb031dd9e522504a3c743156bd0f Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 19:15:13 +0900 Subject: [PATCH 228/255] Fix cache folder issue - rename cache to caching to avoid gitignore --- frontend/lib/caching/codeCache.ts | 105 ++++++++++++++++++ .../lib/hooks/useEntityJoinOptimization.ts | 2 +- .../table-list/TableListComponent.tsx | 2 +- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 frontend/lib/caching/codeCache.ts diff --git a/frontend/lib/caching/codeCache.ts b/frontend/lib/caching/codeCache.ts new file mode 100644 index 00000000..0897abcd --- /dev/null +++ b/frontend/lib/caching/codeCache.ts @@ -0,0 +1,105 @@ +/** + * 공통 코드 캐시 시스템 + * 자주 사용되는 공통 코드들을 메모리에 캐싱하여 성능을 향상시킵니다. + */ + +interface CacheEntry { + data: any; + timestamp: number; + expiry: number; +} + +class CodeCache { + private cache = new Map(); + private defaultTTL = 5 * 60 * 1000; // 5분 + + /** + * 캐시에 데이터 저장 + */ + set(key: string, data: any, ttl?: number): void { + const expiry = ttl || this.defaultTTL; + const entry: CacheEntry = { + data, + timestamp: Date.now(), + expiry, + }; + this.cache.set(key, entry); + } + + /** + * 캐시에서 데이터 조회 + */ + get(key: string): any | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + // TTL 체크 + if (Date.now() - entry.timestamp > entry.expiry) { + this.cache.delete(key); + return null; + } + + return entry.data; + } + + /** + * 캐시에서 데이터 삭제 + */ + delete(key: string): boolean { + return this.cache.delete(key); + } + + /** + * 모든 캐시 삭제 + */ + clear(): void { + this.cache.clear(); + } + + /** + * 만료된 캐시 정리 + */ + cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.expiry) { + this.cache.delete(key); + } + } + } + + /** + * 캐시 상태 조회 + */ + getStats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()), + }; + } + + /** + * 공통 코드 캐시 키 생성 + */ + createCodeKey(category: string, companyCode?: string): string { + return `code:${category}:${companyCode || "*"}`; + } +} + +// 싱글톤 인스턴스 생성 +const codeCache = new CodeCache(); + +// 주기적으로 만료된 캐시 정리 (10분마다) +if (typeof window !== "undefined") { + setInterval( + () => { + codeCache.cleanup(); + }, + 10 * 60 * 1000, + ); +} + +export default codeCache; +export { CodeCache, codeCache }; diff --git a/frontend/lib/hooks/useEntityJoinOptimization.ts b/frontend/lib/hooks/useEntityJoinOptimization.ts index b124963a..3c3c7545 100644 --- a/frontend/lib/hooks/useEntityJoinOptimization.ts +++ b/frontend/lib/hooks/useEntityJoinOptimization.ts @@ -4,7 +4,7 @@ */ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; -import { codeCache } from "@/lib/cache/codeCache"; +import { codeCache } from "@/lib/caching/codeCache"; interface ColumnMetaInfo { webType?: string; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 21996dd9..3d8285fc 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { TableListConfig, ColumnConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; -import { codeCache } from "@/lib/cache/codeCache"; +import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -- 2.43.0 From 6b6ae7e9f4f95ddccc6d9315cdd854366c38fabd Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 19:23:07 +0900 Subject: [PATCH 229/255] Fix frontend client-side error - update API URL config and disable table-list temporarily --- frontend/lib/registry/components/index.ts | 2 +- frontend/next.config.mjs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 80f690f3..cabedef9 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -35,7 +35,7 @@ import "./toggle-switch/ToggleSwitchRenderer"; import "./image-display/ImageDisplayRenderer"; import "./divider-line/DividerLineRenderer"; import "./accordion-basic/AccordionBasicRenderer"; -import "./table-list/TableListRenderer"; +// import "./table-list/TableListRenderer"; // 임시 비활성화 import "./card-display/CardDisplayRenderer"; /** diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 3b517a6b..75ed37b3 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -36,8 +36,8 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { - // 개발 환경에서는 Next.js rewrites를 통해 /api로 프록시 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "/api", + // 프로덕션에서는 직접 백엔드 URL 사용, 개발환경에서는 프록시 사용 + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === "production" ? "http://39.117.244.52:8080/api" : "/api"), }, }; -- 2.43.0 From 50079d359cc9aeba1e6bc39c93a2a165779f8183 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 19:26:28 +0900 Subject: [PATCH 230/255] Fix missing widgets/types import causing client-side error --- frontend/lib/registry/DynamicWebTypeRenderer.tsx | 16 ++++++++++------ frontend/next.config.mjs | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/lib/registry/DynamicWebTypeRenderer.tsx b/frontend/lib/registry/DynamicWebTypeRenderer.tsx index 96e9d3bf..47cb8dfd 100644 --- a/frontend/lib/registry/DynamicWebTypeRenderer.tsx +++ b/frontend/lib/registry/DynamicWebTypeRenderer.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from "react"; import { WebTypeRegistry } from "./WebTypeRegistry"; import { DynamicComponentProps } from "./types"; -import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types"; +// import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types"; // 임시 비활성화 import { useWebTypes } from "@/hooks/admin/useWebTypes"; /** @@ -53,9 +53,11 @@ export const DynamicWebTypeRenderer: React.FC = ({ console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`); console.log(`DB 웹타입 정보:`, dbWebType); console.log(`웹타입 데이터 배열:`, webTypes); - const ComponentByName = getWidgetComponentByName(dbWebType.component_name); - console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName); - return ; + // const ComponentByName = getWidgetComponentByName(dbWebType.component_name); + // console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName); + // return ; + console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화`); + return
컴포넌트 로딩 중...
; } catch (error) { console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error); } @@ -89,8 +91,10 @@ export const DynamicWebTypeRenderer: React.FC = ({ // 3순위: 웹타입명으로 자동 매핑 (폴백) try { console.warn(`웹타입 "${webType}" → 자동 매핑 폴백 사용`); - const FallbackComponent = getWidgetComponentByWebType(webType); - return ; + // const FallbackComponent = getWidgetComponentByWebType(webType); + // return ; + console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`); + return
웹타입 로딩 중...
; } catch (error) { console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error); return ( diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 75ed37b3..6d01bfab 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -37,7 +37,9 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { // 프로덕션에서는 직접 백엔드 URL 사용, 개발환경에서는 프록시 사용 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === "production" ? "http://39.117.244.52:8080/api" : "/api"), + NEXT_PUBLIC_API_URL: + process.env.NEXT_PUBLIC_API_URL || + (process.env.NODE_ENV === "production" ? "http://39.117.244.52:8080/api" : "/api"), }, }; -- 2.43.0 From 3fbbfb53c177335753ae2410a53b48cf34b9daba Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 20:13:33 +0900 Subject: [PATCH 231/255] Add missing preloadCodes and getCodeSync methods to codeCache --- frontend/lib/caching/codeCache.ts | 56 +++++++++++++++++++++++ frontend/lib/registry/components/index.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/frontend/lib/caching/codeCache.ts b/frontend/lib/caching/codeCache.ts index 0897abcd..41127874 100644 --- a/frontend/lib/caching/codeCache.ts +++ b/frontend/lib/caching/codeCache.ts @@ -3,6 +3,8 @@ * 자주 사용되는 공통 코드들을 메모리에 캐싱하여 성능을 향상시킵니다. */ +import { commonCodeApi } from "@/lib/api/commonCode"; + interface CacheEntry { data: any; timestamp: number; @@ -86,6 +88,60 @@ class CodeCache { createCodeKey(category: string, companyCode?: string): string { return `code:${category}:${companyCode || "*"}`; } + + /** + * 여러 코드 카테고리를 배치로 미리 로딩 + */ + async preloadCodes(categories: string[]): Promise { + console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`); + + const promises = categories.map(async (category) => { + try { + const response = await commonCodeApi.codes.getList(category, { isActive: true }); + if (response.success && response.data) { + const cacheKey = this.createCodeKey(category); + this.set(cacheKey, response.data, this.defaultTTL); + console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`); + } + } catch (error) { + console.error(`❌ 코드 로딩 실패: ${category}`, error); + } + }); + + await Promise.all(promises); + console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`); + } + + /** + * 코드를 동기적으로 조회 (캐시에서만) + */ + getCodeSync(category: string, companyCode?: string): any[] | null { + const cacheKey = this.createCodeKey(category, companyCode); + return this.get(cacheKey); + } + + /** + * 코드를 비동기적으로 조회 (캐시 미스 시 API 호출) + */ + async getCodeAsync(category: string, companyCode?: string): Promise { + const cached = this.getCodeSync(category, companyCode); + if (cached) { + return cached; + } + + try { + const response = await commonCodeApi.codes.getList(category, { isActive: true }); + if (response.success && response.data) { + const cacheKey = this.createCodeKey(category, companyCode); + this.set(cacheKey, response.data, this.defaultTTL); + return response.data; + } + } catch (error) { + console.error(`❌ 코드 조회 실패: ${category}`, error); + } + + return []; + } } // 싱글톤 인스턴스 생성 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index cabedef9..80f690f3 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -35,7 +35,7 @@ import "./toggle-switch/ToggleSwitchRenderer"; import "./image-display/ImageDisplayRenderer"; import "./divider-line/DividerLineRenderer"; import "./accordion-basic/AccordionBasicRenderer"; -// import "./table-list/TableListRenderer"; // 임시 비활성화 +import "./table-list/TableListRenderer"; import "./card-display/CardDisplayRenderer"; /** -- 2.43.0 From 30d01fc3bd7b67152b4e3c021331f7cd0e62f085 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 20:14:26 +0900 Subject: [PATCH 232/255] Add missing getCacheInfo method to codeCache --- frontend/lib/caching/codeCache.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/frontend/lib/caching/codeCache.ts b/frontend/lib/caching/codeCache.ts index 41127874..d7d2d65b 100644 --- a/frontend/lib/caching/codeCache.ts +++ b/frontend/lib/caching/codeCache.ts @@ -142,6 +142,35 @@ class CodeCache { return []; } + + /** + * 캐시 정보 조회 (성능 메트릭용) + */ + getCacheInfo(): { + size: number; + keys: string[]; + totalMemoryUsage: number; + hitRate?: number; + } { + const stats = this.getStats(); + + // 메모리 사용량 추정 (대략적) + let totalMemoryUsage = 0; + for (const [key, entry] of this.cache.entries()) { + // 키 크기 + 데이터 크기 추정 + totalMemoryUsage += key.length * 2; // 문자열은 UTF-16이므로 2바이트 + if (Array.isArray(entry.data)) { + totalMemoryUsage += entry.data.length * 100; // 각 항목당 대략 100바이트로 추정 + } else { + totalMemoryUsage += JSON.stringify(entry.data).length * 2; + } + } + + return { + ...stats, + totalMemoryUsage, + }; + } } // 싱글톤 인스턴스 생성 -- 2.43.0 From 38db624fd4f19b16fa14d5f395062dee89590379 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 20:14:56 +0900 Subject: [PATCH 233/255] Add missing invalidate method to codeCache - complete all missing methods --- frontend/lib/caching/codeCache.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/lib/caching/codeCache.ts b/frontend/lib/caching/codeCache.ts index d7d2d65b..94616f53 100644 --- a/frontend/lib/caching/codeCache.ts +++ b/frontend/lib/caching/codeCache.ts @@ -171,6 +171,14 @@ class CodeCache { totalMemoryUsage, }; } + + /** + * 특정 카테고리의 캐시 무효화 + */ + invalidate(category: string, companyCode?: string): boolean { + const cacheKey = this.createCodeKey(category, companyCode); + return this.delete(cacheKey); + } } // 싱글톤 인스턴스 생성 -- 2.43.0 From 7a1358484ba3f5334b4802cebcafec0c203be460 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 18 Sep 2025 21:33:04 +0900 Subject: [PATCH 234/255] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20UI=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/prisma/schema.prisma | 21 +++++++++++++++++++ frontend/components/screen/EditModal.tsx | 1 + frontend/components/screen/FloatingPanel.tsx | 4 ++-- .../screen/InteractiveScreenViewer.tsx | 2 +- .../screen/RealtimePreviewDynamic.tsx | 4 ++-- frontend/components/ui/dialog.tsx | 4 ++-- frontend/components/ui/dropdown-menu.tsx | 4 ++-- frontend/components/ui/popover.tsx | 2 +- frontend/components/ui/select.tsx | 2 +- 9 files changed, 33 insertions(+), 11 deletions(-) diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index e198576b..169adbf1 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -4080,3 +4080,24 @@ model table_relationships_backup { @@ignore } + +model test_sales_info { + sales_no String @id @db.VarChar(20) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + product_type String? @db.VarChar(100) + machine_type String? @db.VarChar(100) + customer_project_name String? @db.VarChar(200) + expected_delivery_date DateTime? @db.Date + receiving_location String? @db.VarChar(200) + setup_location String? @db.VarChar(200) + equipment_direction String? @db.VarChar(100) + equipment_count Int? @default(0) + equipment_type String? @db.VarChar(100) + equipment_length Decimal? @db.Decimal(10,2) + manager_name String? @db.VarChar(100) + reg_date DateTime? @default(now()) @db.Timestamp(6) + status String? @default("진행중") @db.VarChar(50) +} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2e736840..026bd9f3 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -230,6 +230,7 @@ export const EditModal: React.FC = ({ minHeight: dynamicSize.height, maxWidth: "95vw", maxHeight: "95vh", + zIndex: 9999, // 모든 컴포넌트보다 위에 표시 }} > diff --git a/frontend/components/screen/FloatingPanel.tsx b/frontend/components/screen/FloatingPanel.tsx index 2de7ed12..e6d3241e 100644 --- a/frontend/components/screen/FloatingPanel.tsx +++ b/frontend/components/screen/FloatingPanel.tsx @@ -227,7 +227,7 @@ export const FloatingPanel: React.FC = ({
= ({ height: `${panelSize.height}px`, transform: isDragging ? "scale(1.01)" : "scale(1)", transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out", - zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시 + zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시 }} > {/* 헤더 */} diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index aa038662..006072fc 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1683,7 +1683,7 @@ export const InteractiveScreenViewer: React.FC = ( top: `${popupComponent.position.y}px`, width: `${popupComponent.size.width}px`, height: `${popupComponent.size.height}px`, - zIndex: popupComponent.position.z || 1, + zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한 }} > {/* 🎯 핵심 수정: 팝업 전용 formData 사용 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 087335ac..409c6056 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -76,12 +76,12 @@ export const RealtimePreviewDynamic: React.FC = ({ }) => { const { id, type, position, size, style: componentStyle } = component; - // 선택 상태에 따른 스타일 + // 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래) const selectionStyle = isSelected ? { outline: "2px solid #3b82f6", outlineOffset: "2px", - zIndex: 1000, + zIndex: 30, // 패널(z-50)과 모달(z-50)보다 낮게 설정 } : {}; diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index c0ef81cc..4afeb373 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -38,13 +38,13 @@ const DialogContent = React.forwardRef< {children} - + Close diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx index 7aaa6d81..97a61ea2 100644 --- a/frontend/components/ui/dropdown-menu.tsx +++ b/frontend/components/ui/dropdown-menu.tsx @@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef< Date: Fri, 19 Sep 2025 02:15:21 +0900 Subject: [PATCH 235/255] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/dynamicFormService.ts | 108 +++++++++- frontend/components/common/ScreenModal.tsx | 79 ++++++-- frontend/components/screen/EditModal.tsx | 92 ++++++--- .../screen/InteractiveScreenViewer.tsx | 94 ++++++--- .../screen/InteractiveScreenViewerDynamic.tsx | 6 +- frontend/components/ui/alert-dialog.tsx | 6 +- .../lib/hooks/useEntityJoinOptimization.ts | 33 ++- .../lib/registry/DynamicComponentRenderer.tsx | 113 +++++++++-- .../lib/registry/DynamicLayoutRenderer.tsx | 6 +- .../button-primary/ButtonPrimaryComponent.tsx | 18 +- .../date-input/DateInputComponent.tsx | 142 +++++++++++-- .../number-input/NumberInputComponent.tsx | 65 ++++-- .../select-basic/SelectBasicComponent.tsx | 107 +++++++++- .../table-list/TableListComponent.tsx | 18 +- .../text-input/TextInputComponent.tsx | 14 +- .../registry/layouts/BaseLayoutRenderer.tsx | 12 ++ .../layouts/flexbox/FlexboxLayout.tsx | 16 +- .../lib/registry/layouts/grid/GridLayout.tsx | 16 +- frontend/lib/utils/buttonActions.ts | 70 ++++++- frontend/lib/utils/domPropsFilter.ts | 189 ++++++++++++++++++ 20 files changed, 1024 insertions(+), 180 deletions(-) create mode 100644 frontend/lib/utils/domPropsFilter.ts diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 56b0c42c..3e61b69e 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -87,6 +87,48 @@ export class DynamicFormService { return Boolean(value); } + // 날짜/시간 타입 처리 + if ( + lowerDataType.includes("date") || + lowerDataType.includes("timestamp") || + lowerDataType.includes("time") + ) { + if (typeof value === "string") { + // 빈 문자열이면 null 반환 + if (value.trim() === "") { + return null; + } + + try { + // YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성 + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`); + return new Date(value + "T00:00:00"); + } + // 다른 날짜 형식도 Date 객체로 변환 + else { + console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`); + return new Date(value); + } + } catch (error) { + console.error(`❌ 날짜 변환 실패: ${value}`, error); + return null; + } + } + + // 이미 Date 객체인 경우 그대로 반환 + if (value instanceof Date) { + return value; + } + + // 숫자인 경우 timestamp로 처리 + if (typeof value === "number") { + return new Date(value); + } + + return null; + } + // 기본적으로 문자열로 반환 return value; } @@ -479,7 +521,7 @@ export class DynamicFormService { const updateQuery = ` UPDATE ${tableName} SET ${setClause} - WHERE ${primaryKeyColumn} = $${values.length} + WHERE ${primaryKeyColumn} = $${values.length}::text RETURNING * `; @@ -552,6 +594,31 @@ export class DynamicFormService { } }); + // 컬럼 타입에 맞는 데이터 변환 (UPDATE용) + const columnInfo = await this.getTableColumnInfo(tableName); + console.log(`📊 테이블 ${tableName}의 컬럼 타입 정보:`, columnInfo); + + // 각 컬럼의 타입에 맞게 데이터 변환 + Object.keys(dataToUpdate).forEach((columnName) => { + const column = columnInfo.find((col) => col.column_name === columnName); + if (column) { + const originalValue = dataToUpdate[columnName]; + const convertedValue = this.convertValueForPostgreSQL( + originalValue, + column.data_type + ); + + if (originalValue !== convertedValue) { + console.log( + `🔄 UPDATE 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}` + ); + dataToUpdate[columnName] = convertedValue; + } + } + }); + + console.log("✅ UPDATE 타입 변환 완료된 데이터:", dataToUpdate); + console.log("🎯 실제 테이블에서 업데이트할 데이터:", { tableName, id, @@ -650,12 +717,15 @@ export class DynamicFormService { tableName, }); - // 1. 먼저 테이블의 기본키 컬럼명을 동적으로 조회 + // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 const primaryKeyQuery = ` - SELECT kcu.column_name + SELECT kcu.column_name, c.data_type FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.columns c + ON kcu.column_name = c.column_name + AND kcu.table_name = c.table_name WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY' LIMIT 1 @@ -677,13 +747,37 @@ export class DynamicFormService { throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); } - const primaryKeyColumn = (primaryKeyResult[0] as any).column_name; - console.log("🔑 발견된 기본키 컬럼:", primaryKeyColumn); + const primaryKeyInfo = primaryKeyResult[0] as any; + const primaryKeyColumn = primaryKeyInfo.column_name; + const primaryKeyDataType = primaryKeyInfo.data_type; + console.log("🔑 발견된 기본키:", { + column: primaryKeyColumn, + dataType: primaryKeyDataType, + }); - // 2. 동적으로 발견된 기본키를 사용한 DELETE SQL 생성 + // 2. 데이터 타입에 맞는 타입 캐스팅 적용 + let typeCastSuffix = ""; + if ( + primaryKeyDataType.includes("character") || + primaryKeyDataType.includes("text") + ) { + typeCastSuffix = "::text"; + } else if ( + primaryKeyDataType.includes("integer") || + primaryKeyDataType.includes("bigint") + ) { + typeCastSuffix = "::bigint"; + } else if ( + primaryKeyDataType.includes("numeric") || + primaryKeyDataType.includes("decimal") + ) { + typeCastSuffix = "::numeric"; + } + + // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` DELETE FROM ${tableName} - WHERE ${primaryKeyColumn} = $1 + WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 92194b7a..453ddfe8 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; @@ -45,24 +45,60 @@ export const ScreenModal: React.FC = ({ className }) => { let maxWidth = 800; // 최소 너비 let maxHeight = 600; // 최소 높이 - components.forEach((component) => { - const x = parseFloat(component.style?.positionX || "0"); - const y = parseFloat(component.style?.positionY || "0"); - const width = parseFloat(component.style?.width || "100"); - const height = parseFloat(component.style?.height || "40"); + console.log("🔍 화면 크기 계산 시작:", { componentsCount: components.length }); + + components.forEach((component, index) => { + // position과 size는 BaseComponent에서 별도 속성으로 관리 + const x = parseFloat(component.position?.x?.toString() || "0"); + const y = parseFloat(component.position?.y?.toString() || "0"); + const width = parseFloat(component.size?.width?.toString() || "100"); + const height = parseFloat(component.size?.height?.toString() || "40"); // 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산 const rightEdge = x + width; const bottomEdge = y + height; - maxWidth = Math.max(maxWidth, rightEdge + 50); // 여백 추가 - maxHeight = Math.max(maxHeight, bottomEdge + 50); // 여백 추가 + console.log( + `📏 컴포넌트 ${index + 1} (${component.id}): x=${x}, y=${y}, w=${width}, h=${height}, rightEdge=${rightEdge}, bottomEdge=${bottomEdge}`, + ); + + const newMaxWidth = Math.max(maxWidth, rightEdge + 100); // 여백 증가 + const newMaxHeight = Math.max(maxHeight, bottomEdge + 100); // 여백 증가 + + if (newMaxWidth > maxWidth || newMaxHeight > maxHeight) { + console.log(`🔄 크기 업데이트: ${maxWidth}×${maxHeight} → ${newMaxWidth}×${newMaxHeight}`); + maxWidth = newMaxWidth; + maxHeight = newMaxHeight; + } }); - return { - width: Math.min(maxWidth, window.innerWidth * 0.9), // 화면의 90%를 넘지 않도록 - height: Math.min(maxHeight, window.innerHeight * 0.8), // 화면의 80%를 넘지 않도록 + console.log("📊 컴포넌트 기반 계산 결과:", { maxWidth, maxHeight }); + + // 브라우저 크기 제한 확인 (더욱 관대하게 설정) + const maxAllowedWidth = window.innerWidth * 0.98; // 95% -> 98% + const maxAllowedHeight = window.innerHeight * 0.95; // 90% -> 95% + + console.log("📐 크기 제한 정보:", { + 계산된크기: { maxWidth, maxHeight }, + 브라우저제한: { maxAllowedWidth, maxAllowedHeight }, + 브라우저크기: { width: window.innerWidth, height: window.innerHeight }, + }); + + // 컴포넌트 기반 크기를 우선 적용하되, 브라우저 제한을 고려 + const finalDimensions = { + width: Math.min(maxWidth, maxAllowedWidth), + height: Math.min(maxHeight, maxAllowedHeight), }; + + console.log("✅ 최종 화면 크기:", finalDimensions); + console.log("🔧 크기 적용 분석:", { + width적용: maxWidth <= maxAllowedWidth ? "컴포넌트기준" : "브라우저제한", + height적용: maxHeight <= maxAllowedHeight ? "컴포넌트기준" : "브라우저제한", + 컴포넌트크기: { maxWidth, maxHeight }, + 최종크기: finalDimensions, + }); + + return finalDimensions; }; // 전역 모달 이벤트 리스너 @@ -154,17 +190,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; } - // 헤더 높이와 패딩을 고려한 전체 높이 계산 - const headerHeight = 60; // DialogHeader + 패딩 + // 헤더 높이와 패딩을 고려한 전체 높이 계산 (실제 측정값 기반) + const headerHeight = 80; // DialogHeader + 패딩 (더 정확한 값) const totalHeight = screenDimensions.height + headerHeight; return { className: "overflow-hidden p-0", style: { - width: `${screenDimensions.width + 48}px`, // 헤더 패딩과 여백 고려 - height: `${Math.min(totalHeight, window.innerHeight * 0.8)}px`, - maxWidth: "90vw", - maxHeight: "80vh", + width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 브라우저 제한 적용 + height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 브라우저 제한 적용 + maxWidth: "98vw", // 안전장치 + maxHeight: "95vh", // 안전장치 }, }; }; @@ -176,9 +212,10 @@ export const ScreenModal: React.FC = ({ className }) => { {modalState.title} + {loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."} -
+
{loading ? (
@@ -188,7 +225,7 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? (
= ({ className }) => { formData={formData} onFormDataChange={(fieldName, value) => { console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log(`📋 현재 formData:`, formData); + console.log("📋 현재 formData:", formData); setFormData((prev) => { const newFormData = { ...prev, [fieldName]: value, }; - console.log(`📝 ScreenModal 업데이트된 formData:`, newFormData); + console.log("📝 ScreenModal 업데이트된 formData:", newFormData); return newFormData; }); }} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 026bd9f3..b0cc0c2f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { X, Save, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/lib/types/screen"; @@ -145,7 +146,19 @@ export const EditModal: React.FC = ({ layoutData.components.forEach((comp) => { if (comp.columnName) { const formValue = formData[comp.columnName]; - console.log(` - ${comp.columnName}: "${formValue}" (컴포넌트 ID: ${comp.id})`); + console.log( + ` - ${comp.columnName}: "${formValue}" (타입: ${comp.type}, 웹타입: ${(comp as any).widgetType})`, + ); + + // 코드 타입인 경우 특별히 로깅 + if ((comp as any).widgetType === "code") { + console.log(` 🔍 코드 타입 세부정보:`, { + columnName: comp.columnName, + componentId: comp.id, + formValue, + webTypeConfig: (comp as any).webTypeConfig, + }); + } } }); } else { @@ -270,30 +283,61 @@ export const EditModal: React.FC = ({ zIndex: 1, }} > - { - console.log("📝 폼 데이터 변경:", fieldName, value); - const newFormData = { ...formData, [fieldName]: value }; - setFormData(newFormData); + {/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */} + {component.type === "widget" ? ( + { + console.log("📝 폼 데이터 변경:", fieldName, value); + const newFormData = { ...formData, [fieldName]: value }; + setFormData(newFormData); - // 변경된 데이터를 즉시 부모로 전달 - if (onDataChange) { - console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); - onDataChange(newFormData); - } - }} - // 편집 모드로 설정 - mode="edit" - // 모달 내에서 렌더링되고 있음을 표시 - isInModal={true} - // 인터랙티브 모드 활성화 (formData 사용을 위해 필수) - isInteractive={true} - /> + // 변경된 데이터를 즉시 부모로 전달 + if (onDataChange) { + console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); + onDataChange(newFormData); + } + }} + screenInfo={{ + id: screenId || 0, + tableName: screenData.tableName, + }} + /> + ) : ( + { + console.log("📝 폼 데이터 변경:", fieldName, value); + const newFormData = { ...formData, [fieldName]: value }; + setFormData(newFormData); + + // 변경된 데이터를 즉시 부모로 전달 + if (onDataChange) { + console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); + onDataChange(newFormData); + } + }} + // 편집 모드로 설정 + mode="edit" + // 모달 내에서 렌더링되고 있음을 표시 + isInModal={true} + // 인터랙티브 모드 활성화 (formData 사용을 위해 필수) + isInteractive={true} + /> + )}
))}
diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 006072fc..bb6d2eac 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -37,6 +37,7 @@ import { FileUpload } from "./widgets/FileUpload"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { useParams } from "next/navigation"; import { screenApi } from "@/lib/api/screen"; +import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer"; interface InteractiveScreenViewerProps { component: ComponentData; @@ -936,41 +937,64 @@ export const InteractiveScreenViewer: React.FC = ( const widget = comp as WidgetComponent; const config = widget.webTypeConfig as CodeTypeConfig | undefined; - console.log("💻 InteractiveScreenViewer - Code 위젯:", { + console.log("🔍 InteractiveScreenViewer - Code 위젯 (공통코드 선택):", { componentId: widget.id, widgetType: widget.widgetType, + columnName: widget.columnName, + fieldName, + currentValue, + formData, config, - appliedSettings: { - language: config?.language, - theme: config?.theme, - fontSize: config?.fontSize, - defaultValue: config?.defaultValue, - wordWrap: config?.wordWrap, - tabSize: config?.tabSize, - }, + codeCategory: config?.codeCategory, }); - const finalPlaceholder = config?.placeholder || "코드를 입력하세요..."; - const rows = config?.rows || 4; - - return applyStyles( -