Compare commits
4 Commits
b42c9bb558
...
16b8f9d0c2
| Author | SHA1 | Date |
|---|---|---|
|
|
16b8f9d0c2 | |
|
|
bc24cccdf1 | |
|
|
7bb7f1621f | |
|
|
307faba089 |
|
|
@ -763,12 +763,22 @@ export const getBatchTranslations = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, menuCode, userLang } = req.query;
|
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("다국어 텍스트 배치 조회 요청", {
|
logger.info("다국어 텍스트 배치 조회 요청", {
|
||||||
companyCode,
|
companyCode: finalCompanyCode,
|
||||||
menuCode,
|
menuCode: finalMenuCode,
|
||||||
userLang,
|
userLang: finalUserLang,
|
||||||
keyCount: langKeys?.length || 0,
|
keyCount: langKeys?.length || 0,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
|
|
@ -785,7 +795,7 @@ export const getBatchTranslations = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!companyCode || !userLang) {
|
if (!finalCompanyCode || !finalUserLang) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "companyCode와 userLang은 필수입니다.",
|
message: "companyCode와 userLang은 필수입니다.",
|
||||||
|
|
@ -809,9 +819,9 @@ export const getBatchTranslations = async (
|
||||||
try {
|
try {
|
||||||
const multiLangService = new MultiLangService(client);
|
const multiLangService = new MultiLangService(client);
|
||||||
const translations = await multiLangService.getBatchTranslations({
|
const translations = await multiLangService.getBatchTranslations({
|
||||||
companyCode: companyCode as string,
|
companyCode: finalCompanyCode as string,
|
||||||
menuCode: menuCode as string,
|
menuCode: finalMenuCode as string,
|
||||||
userLang: userLang as string,
|
userLang: finalUserLang as string,
|
||||||
langKeys,
|
langKeys,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,970 @@
|
||||||
|
# 다국어 관리 시스템 가이드
|
||||||
|
|
||||||
|
## 📋 목차
|
||||||
|
|
||||||
|
1. [시스템 개요](#시스템-개요)
|
||||||
|
2. [아키텍처 구조](#아키텍처-구조)
|
||||||
|
3. [데이터베이스 구조](#데이터베이스-구조)
|
||||||
|
4. [핵심 컴포넌트](#핵심-컴포넌트)
|
||||||
|
5. [사용법 가이드](#사용법-가이드)
|
||||||
|
6. [페이지 적용 방법](#페이지-적용-방법)
|
||||||
|
7. [새로운 다국어 키 추가](#새로운-다국어-키-추가)
|
||||||
|
8. [문제 해결](#문제-해결)
|
||||||
|
9. [모범 사례](#모범-사례)
|
||||||
|
|
||||||
|
## 🎯 시스템 개요
|
||||||
|
|
||||||
|
### 다국어 시스템이란?
|
||||||
|
|
||||||
|
현재 ERP 시스템은 한국어와 영어를 지원하는 다국어 시스템을 구축하고 있습니다. 사용자가 설정한 언어에 따라 UI의 모든 텍스트가 자동으로 번역되어 표시됩니다.
|
||||||
|
|
||||||
|
### 지원 언어
|
||||||
|
|
||||||
|
- **한국어 (KR)**: 기본 언어
|
||||||
|
- **영어 (EN)**: 사용자 설정 가능
|
||||||
|
|
||||||
|
### 주요 특징
|
||||||
|
|
||||||
|
- **실시간 번역**: 페이지 로드 시 즉시 번역 적용
|
||||||
|
- **배치 처리**: 여러 키를 한 번에 조회하여 성능 최적화
|
||||||
|
- **캐싱 시스템**: 번역 결과를 메모리에 캐싱하여 중복 요청 방지
|
||||||
|
- **폴백 시스템**: 번역 실패 시 기본 텍스트 표시
|
||||||
|
|
||||||
|
## 🏗️ 아키텍처 구조
|
||||||
|
|
||||||
|
### 전체 구조도
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ Backend │ │ Database │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||||
|
│ │ useMultiLang│ │ │ │ Multilang │ │ │ │ multilang │ │
|
||||||
|
│ │ Hook │ │ │ │ Controller │ │ │ │ Table │ │
|
||||||
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||||
|
│ │ Multilang │ │ │ │ Multilang │ │ │ │ lang_keys │ │
|
||||||
|
│ │ Utils │ │ │ │ Service │ │ │ │ Table │ │
|
||||||
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||||
|
│ │ UI │ │ │ │ Batch API │ │ │ │ translations│ │
|
||||||
|
│ │ Components │ │ │ │ Endpoint │ │ │ │ Table │ │
|
||||||
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 흐름
|
||||||
|
|
||||||
|
1. **사용자 언어 설정**: `useMultiLang` 훅에서 사용자 언어 확인
|
||||||
|
2. **다국어 키 정의**: 컴포넌트에서 필요한 다국어 키 상수 정의
|
||||||
|
3. **배치 요청**: 여러 키를 한 번에 백엔드로 전송
|
||||||
|
4. **번역 조회**: 백엔드에서 데이터베이스에서 번역 데이터 조회
|
||||||
|
5. **결과 반환**: 번역된 텍스트를 프론트엔드로 반환
|
||||||
|
6. **UI 업데이트**: 번역된 텍스트로 컴포넌트 렌더링
|
||||||
|
|
||||||
|
## 🗄️ 데이터베이스 구조
|
||||||
|
|
||||||
|
### 실제 테이블 구조
|
||||||
|
|
||||||
|
#### 1. language_master (언어 마스터)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.language_master (
|
||||||
|
lang_code varchar(10) NOT NULL PRIMARY KEY, -- 언어 코드 (KR, EN)
|
||||||
|
lang_name varchar(50) NOT NULL, -- 언어명 (Korean, English)
|
||||||
|
lang_native varchar(50) NOT NULL, -- 원어명 (한국어, English)
|
||||||
|
is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부
|
||||||
|
sort_order int4 DEFAULT 0, -- 정렬 순서
|
||||||
|
created_date timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by varchar(50),
|
||||||
|
updated_date timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by varchar(50)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. multi_lang_key_master (다국어 키 마스터)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.multi_lang_key_master (
|
||||||
|
key_id serial4 NOT NULL PRIMARY KEY, -- 키 ID (자동 증가)
|
||||||
|
company_code varchar(20) DEFAULT '*' NOT NULL, -- 회사 코드 (* = 공통)
|
||||||
|
lang_key varchar(100) NOT NULL, -- 다국어 키 (예: button.add)
|
||||||
|
description text, -- 키 설명
|
||||||
|
is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부
|
||||||
|
menu_name varchar(50), -- 메뉴명 (사용하지 않음)
|
||||||
|
created_date timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by varchar(50),
|
||||||
|
updated_date timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by varchar(50),
|
||||||
|
CONSTRAINT uk_lang_key_company UNIQUE (company_code, lang_key)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. multi_lang_text (다국어 텍스트)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.multi_lang_text (
|
||||||
|
text_id serial4 NOT NULL PRIMARY KEY, -- 텍스트 ID (자동 증가)
|
||||||
|
key_id int4 NOT NULL, -- 키 마스터의 key_id
|
||||||
|
lang_code varchar(10) NOT NULL, -- 언어 코드 (KR, EN)
|
||||||
|
lang_text text NOT NULL, -- 번역된 텍스트
|
||||||
|
is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부
|
||||||
|
created_date timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by varchar(50),
|
||||||
|
updated_date timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by varchar(50),
|
||||||
|
CONSTRAINT multi_lang_text_key_id_lang_code_key UNIQUE (key_id, lang_code),
|
||||||
|
CONSTRAINT multi_lang_text_key_id_fkey FOREIGN KEY (key_id)
|
||||||
|
REFERENCES multi_lang_key_master(key_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT multi_lang_text_lang_code_fkey FOREIGN KEY (lang_code)
|
||||||
|
REFERENCES language_master(lang_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테이블 관계도
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ language_master │ │multi_lang_key_ │ │ multi_lang_text │
|
||||||
|
│ │ │ master │ │ │
|
||||||
|
│ - lang_code │ │ - key_id │ │ - text_id │
|
||||||
|
│ - lang_name │ │ - company_code │ │ - lang_key │
|
||||||
|
│ - lang_native │ │ - description │ │ - key_id │
|
||||||
|
│ - is_active │ │ - is_active │ │ - lang_code │
|
||||||
|
│ - sort_order │ │ - created_date │ │ - lang_text │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
└───────────────────────┼───────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 외래키 관계 │
|
||||||
|
│ │
|
||||||
|
│ lang_code │
|
||||||
|
│ key_id │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 저장 예시
|
||||||
|
|
||||||
|
#### 1. 언어 마스터 데이터
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO language_master (lang_code, lang_name, lang_native) VALUES
|
||||||
|
('KR', 'Korean', '한국어'),
|
||||||
|
('EN', 'English', 'English');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 다국어 키 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO multi_lang_key_master (lang_key, company_code, description) VALUES
|
||||||
|
('button.add', '*', '추가 버튼'),
|
||||||
|
('button.edit', '*', '수정 버튼'),
|
||||||
|
('menu.title', '*', '메뉴 제목');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 번역 텍스트 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 한국어 번역
|
||||||
|
INSERT INTO multi_lang_text (key_id, lang_code, lang_text)
|
||||||
|
SELECT km.key_id, 'KR',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'button.add' THEN '추가'
|
||||||
|
WHEN 'button.edit' THEN '수정'
|
||||||
|
WHEN 'menu.title' THEN '메뉴 관리'
|
||||||
|
END
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('button.add', 'button.edit', 'menu.title')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
|
||||||
|
-- 영어 번역
|
||||||
|
INSERT INTO multi_lang_text (key_id, lang_code, lang_text)
|
||||||
|
SELECT km.key_id, 'EN',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'button.add' THEN 'Add'
|
||||||
|
WHEN 'button.edit' THEN 'Edit'
|
||||||
|
WHEN 'menu.title' THEN 'Menu Management'
|
||||||
|
END
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('button.add', 'button.edit', 'menu.title')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 핵심 컴포넌트
|
||||||
|
|
||||||
|
### 1. useMultiLang 훅
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/hooks/useMultiLang.ts
|
||||||
|
export const useMultiLang = (options: { companyCode?: string } = {}) => {
|
||||||
|
const [userLang, setUserLang] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 사용자 언어 조회 및 설정
|
||||||
|
// 전역 언어 상태 동기화
|
||||||
|
// 언어 변경 처리
|
||||||
|
|
||||||
|
return {
|
||||||
|
userLang, // 현재 사용자 언어 (KR, EN)
|
||||||
|
getText, // 다국어 텍스트 조회 함수
|
||||||
|
changeLang, // 언어 변경 함수
|
||||||
|
companyCode, // 회사 코드
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
|
||||||
|
- 사용자 언어 설정 및 조회
|
||||||
|
- 전역 언어 상태 관리
|
||||||
|
- 언어 변경 시 자동 동기화
|
||||||
|
|
||||||
|
**사용 예시:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { userLang, getText, changeLang } = useMultiLang({ companyCode: "*" });
|
||||||
|
|
||||||
|
// 언어 변경
|
||||||
|
await changeLang("EN");
|
||||||
|
|
||||||
|
// 다국어 텍스트 조회
|
||||||
|
const text = await getText("menu.management", "button.add");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Multilang Utils
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/utils/multilang.ts
|
||||||
|
export const MENU_MANAGEMENT_KEYS = {
|
||||||
|
TITLE: "menu.management.title",
|
||||||
|
BUTTON_ADD: "button.add",
|
||||||
|
BUTTON_EDIT: "button.edit",
|
||||||
|
// ... 더 많은 키들
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 배치 조회 함수
|
||||||
|
export async function getMultilangText(
|
||||||
|
key: string,
|
||||||
|
companyCode: string = "*",
|
||||||
|
menuCode: string = "MENU_MANAGEMENT",
|
||||||
|
userLang: string = "KR"
|
||||||
|
): Promise<string>;
|
||||||
|
|
||||||
|
// 동기 조회 함수 (캐시에서만)
|
||||||
|
export function getMultilangTextSync(
|
||||||
|
key: string,
|
||||||
|
companyCode: string = "*",
|
||||||
|
menuCode: string = "MENU_MANAGEMENT",
|
||||||
|
userLang: string = "KR"
|
||||||
|
): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
|
||||||
|
- 다국어 키 상수 정의
|
||||||
|
- 배치 처리로 성능 최적화
|
||||||
|
- 캐싱 시스템으로 중복 요청 방지
|
||||||
|
- 동기/비동기 조회 지원
|
||||||
|
|
||||||
|
### 3. Backend API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend/src/controllers/multilangController.ts
|
||||||
|
@Post("/batch")
|
||||||
|
async getBatchTranslations(
|
||||||
|
@Body() body: { langKeys: string[] },
|
||||||
|
@Query() query: { companyCode: string; menuCode: string; userLang: string }
|
||||||
|
): Promise<ApiResponse<Record<string, string>>>
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 엔드포인트:**
|
||||||
|
|
||||||
|
- **POST** `/multilang/batch` - 여러 키를 한 번에 조회
|
||||||
|
- **GET** `/multilang/:key` - 개별 키 조회 (사용하지 않음)
|
||||||
|
|
||||||
|
**요청 파라미터:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
langKeys: ["button.add", "button.edit", "menu.title"],
|
||||||
|
companyCode: "*", // 회사 코드 (* = 전체)
|
||||||
|
menuCode: "MENU_MANAGEMENT", // 메뉴 코드
|
||||||
|
userLang: "KR" // 사용자 언어
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 형식:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
"button.add": "추가",
|
||||||
|
"button.edit": "수정",
|
||||||
|
"menu.title": "메뉴 관리"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 사용법 가이드
|
||||||
|
|
||||||
|
### 1. 기본 사용법
|
||||||
|
|
||||||
|
#### 단계별 가이드
|
||||||
|
|
||||||
|
1. **다국어 키 상수 정의**
|
||||||
|
2. **컴포넌트에서 다국어 텍스트 로드**
|
||||||
|
3. **UI에 번역된 텍스트 적용**
|
||||||
|
|
||||||
|
#### 예시 코드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 다국어 키 상수 정의
|
||||||
|
const MENU_KEYS = ["menu.title", "button.add", "button.edit"];
|
||||||
|
|
||||||
|
// 2. 다국어 텍스트 로드
|
||||||
|
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTexts = async () => {
|
||||||
|
const response = await apiClient.post("/multilang/batch", {
|
||||||
|
langKeys: MENU_KEYS,
|
||||||
|
companyCode: "*",
|
||||||
|
menuCode: "MENU_MANAGEMENT",
|
||||||
|
userLang: userLang,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setUiTexts(response.data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userLang) {
|
||||||
|
loadTexts();
|
||||||
|
}
|
||||||
|
}, [userLang]);
|
||||||
|
|
||||||
|
// 3. UI에 적용
|
||||||
|
const getText = (key: string) => uiTexts[key] || key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{getText("menu.title")}</h1>
|
||||||
|
<button>{getText("button.add")}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 고급 사용법
|
||||||
|
|
||||||
|
#### 배치 처리 최적화
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 여러 키를 한 번에 조회하여 API 호출 최소화
|
||||||
|
const LANG_KEYS = [
|
||||||
|
"form.title",
|
||||||
|
"form.name.label",
|
||||||
|
"form.name.placeholder",
|
||||||
|
"form.submit.button",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 한 번의 API 호출로 모든 키 조회
|
||||||
|
const translations = await getMultilangTextBatch(LANG_KEYS);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 폴백 시스템
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getText = (key: string, fallback?: string) => {
|
||||||
|
// 1. 번역된 텍스트 우선
|
||||||
|
if (uiTexts[key]) return uiTexts[key];
|
||||||
|
|
||||||
|
// 2. 폴백 텍스트 사용
|
||||||
|
if (fallback) return fallback;
|
||||||
|
|
||||||
|
// 3. 키 자체 반환 (디버깅용)
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 페이지 적용 방법
|
||||||
|
|
||||||
|
### 1. 새로운 페이지에 다국어 적용하기
|
||||||
|
|
||||||
|
#### Step 1: 다국어 키 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// constants/pageKeys.ts
|
||||||
|
export const PAGE_KEYS = {
|
||||||
|
// 페이지 제목
|
||||||
|
TITLE: "page.title",
|
||||||
|
DESCRIPTION: "page.description",
|
||||||
|
|
||||||
|
// 폼 요소
|
||||||
|
FORM_NAME: "form.name",
|
||||||
|
FORM_EMAIL: "form.email",
|
||||||
|
FORM_SUBMIT: "form.submit",
|
||||||
|
|
||||||
|
// 메시지
|
||||||
|
SUCCESS_MESSAGE: "message.success",
|
||||||
|
ERROR_MESSAGE: "message.error",
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 컴포넌트에서 다국어 텍스트 로드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/MyPage.tsx
|
||||||
|
import { PAGE_KEYS } from "@/constants/pageKeys";
|
||||||
|
|
||||||
|
export const MyPage: React.FC = () => {
|
||||||
|
const { userLang } = useMultiLang();
|
||||||
|
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 다국어 텍스트 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTexts = async () => {
|
||||||
|
if (!userLang) return;
|
||||||
|
|
||||||
|
const response = await apiClient.post("/multilang/batch", {
|
||||||
|
langKeys: Object.values(PAGE_KEYS),
|
||||||
|
companyCode: "*",
|
||||||
|
menuCode: "MY_PAGE",
|
||||||
|
userLang: userLang,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setUiTexts(response.data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTexts();
|
||||||
|
}, [userLang]);
|
||||||
|
|
||||||
|
// 텍스트 가져오기 함수
|
||||||
|
const getText = (key: string) => uiTexts[key] || key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{getText(PAGE_KEYS.TITLE)}</h1>
|
||||||
|
<p>{getText(PAGE_KEYS.DESCRIPTION)}</p>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<label>{getText(PAGE_KEYS.FORM_NAME)}</label>
|
||||||
|
<input placeholder={getText(PAGE_KEYS.FORM_NAME)} />
|
||||||
|
|
||||||
|
<button type="submit">{getText(PAGE_KEYS.FORM_SUBMIT)}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: 데이터베이스에 번역 데이터 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. 다국어 키 마스터에 키 등록
|
||||||
|
INSERT INTO multi_lang_key_master (
|
||||||
|
lang_key,
|
||||||
|
company_code,
|
||||||
|
description,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
) VALUES
|
||||||
|
('page.title', '*', '페이지 제목', 'Y', now(), 'system'),
|
||||||
|
('page.description', '*', '페이지 설명', 'Y', now(), 'system'),
|
||||||
|
('form.name', '*', '이름', 'Y', now(), 'system'),
|
||||||
|
('form.email', '*', '이메일', 'Y', now(), 'system'),
|
||||||
|
('form.submit', '*', '제출', 'Y', now(), 'system');
|
||||||
|
|
||||||
|
-- 2. 한국어 번역 텍스트 등록
|
||||||
|
INSERT INTO multi_lang_text (
|
||||||
|
key_id,
|
||||||
|
lang_code,
|
||||||
|
lang_text,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
km.key_id,
|
||||||
|
'KR',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'page.title' THEN '내 페이지'
|
||||||
|
WHEN 'page.description' THEN '이것은 내 페이지입니다'
|
||||||
|
WHEN 'form.name' THEN '이름'
|
||||||
|
WHEN 'form.email' THEN '이메일'
|
||||||
|
WHEN 'form.submit' THEN '제출'
|
||||||
|
END,
|
||||||
|
'Y',
|
||||||
|
now(),
|
||||||
|
'system'
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('page.title', 'page.description', 'form.name', 'form.email', 'form.submit')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
|
||||||
|
-- 3. 영어 번역 텍스트 등록
|
||||||
|
INSERT INTO multi_lang_text (
|
||||||
|
key_id,
|
||||||
|
lang_code,
|
||||||
|
lang_text,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
km.key_id,
|
||||||
|
'EN',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'page.title' THEN 'My Page'
|
||||||
|
WHEN 'page.description' THEN 'This is my page'
|
||||||
|
WHEN 'form.name' THEN 'Name'
|
||||||
|
WHEN 'form.email' THEN 'Email'
|
||||||
|
WHEN 'form.submit' THEN 'Submit'
|
||||||
|
END,
|
||||||
|
'Y',
|
||||||
|
now(),
|
||||||
|
'system'
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('page.title', 'page.description', 'form.name', 'form.email', 'form.submit')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 기존 페이지에 다국어 적용하기
|
||||||
|
|
||||||
|
#### Step 1: 하드코딩된 텍스트 찾기
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 변경 전
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>메뉴 관리</h1>
|
||||||
|
<button>추가</button>
|
||||||
|
<button>수정</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 변경 후
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{getText("menu.management.title")}</h1>
|
||||||
|
<button>{getText("button.add")}</button>
|
||||||
|
<button>{getText("button.edit")}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 다국어 키 상수 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 키에 새로운 키 추가
|
||||||
|
export const EXISTING_KEYS = [
|
||||||
|
// 기존 키들...
|
||||||
|
"menu.management.title",
|
||||||
|
"button.add",
|
||||||
|
"button.edit",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: 번역 데이터 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. 다국어 키 마스터에 키 등록
|
||||||
|
INSERT INTO multi_lang_key_master (
|
||||||
|
lang_key,
|
||||||
|
company_code,
|
||||||
|
description,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
) VALUES
|
||||||
|
('menu.management.title', '*', '메뉴 관리 제목', 'Y', now(), 'system'),
|
||||||
|
('button.add', '*', '추가 버튼', 'Y', now(), 'system'),
|
||||||
|
('button.edit', '*', '수정 버튼', 'Y', now(), 'system');
|
||||||
|
|
||||||
|
-- 2. 한국어 번역 텍스트 등록
|
||||||
|
INSERT INTO multi_lang_text (
|
||||||
|
key_id,
|
||||||
|
lang_code,
|
||||||
|
lang_text,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
km.key_id,
|
||||||
|
'KR',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'menu.management.title' THEN '메뉴 관리'
|
||||||
|
WHEN 'button.add' THEN '추가'
|
||||||
|
WHEN 'button.edit' THEN '수정'
|
||||||
|
END,
|
||||||
|
'Y',
|
||||||
|
now(),
|
||||||
|
'system'
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('menu.management.title', 'button.add', 'button.edit')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
|
||||||
|
-- 3. 영어 번역 텍스트 등록
|
||||||
|
INSERT INTO multi_lang_text (
|
||||||
|
key_id,
|
||||||
|
lang_code,
|
||||||
|
lang_text,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
km.key_id,
|
||||||
|
'EN',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'menu.management.title' THEN 'Menu Management'
|
||||||
|
WHEN 'button.add' THEN 'Add'
|
||||||
|
WHEN 'button.edit' THEN 'Edit'
|
||||||
|
END,
|
||||||
|
'Y',
|
||||||
|
now(),
|
||||||
|
'system'
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('menu.management.title', 'button.add', 'button.edit')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
```
|
||||||
|
|
||||||
|
## ➕ 새로운 다국어 키 추가
|
||||||
|
|
||||||
|
### 1. 프론트엔드에서 키 추가
|
||||||
|
|
||||||
|
#### Step 1: 키 상수 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// constants/multilang.ts
|
||||||
|
export const NEW_FEATURE_KEYS = {
|
||||||
|
// 새로운 기능 관련 키들
|
||||||
|
FEATURE_TITLE: "new.feature.title",
|
||||||
|
FEATURE_DESCRIPTION: "new.feature.description",
|
||||||
|
FEATURE_BUTTON: "new.feature.button",
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 컴포넌트에서 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NEW_FEATURE_KEYS } from "@/constants/multilang";
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
const getText = (key: string) => uiTexts[key] || key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>{getText(NEW_FEATURE_KEYS.FEATURE_TITLE)}</h2>
|
||||||
|
<p>{getText(NEW_FEATURE_KEYS.FEATURE_DESCRIPTION)}</p>
|
||||||
|
<button>{getText(NEW_FEATURE_KEYS.FEATURE_BUTTON)}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 백엔드에서 번역 데이터 등록
|
||||||
|
|
||||||
|
#### Step 1: 다국어 키 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- multi_lang_key_master 테이블에 새로운 키 등록
|
||||||
|
INSERT INTO multi_lang_key_master (
|
||||||
|
lang_key,
|
||||||
|
company_code,
|
||||||
|
description,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
) VALUES
|
||||||
|
('new.feature.title', '*', '새로운 기능 제목', 'Y', now(), 'system'),
|
||||||
|
('new.feature.description', '*', '새로운 기능 설명', 'Y', now(), 'system'),
|
||||||
|
('new.feature.button', '*', '새로운 기능 버튼', 'Y', now(), 'system');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 번역 텍스트 등록
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 한국어 번역 텍스트 등록
|
||||||
|
INSERT INTO multi_lang_text (
|
||||||
|
key_id,
|
||||||
|
lang_code,
|
||||||
|
lang_text,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
km.key_id,
|
||||||
|
'KR',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'new.feature.title' THEN '새로운 기능'
|
||||||
|
WHEN 'new.feature.description' THEN '이것은 새로운 기능입니다'
|
||||||
|
WHEN 'new.feature.button' THEN '시작하기'
|
||||||
|
END,
|
||||||
|
'Y',
|
||||||
|
now(),
|
||||||
|
'system'
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('new.feature.title', 'new.feature.description', 'new.feature.button')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
|
||||||
|
-- 영어 번역 텍스트 등록
|
||||||
|
INSERT INTO multi_lang_text (
|
||||||
|
key_id,
|
||||||
|
lang_code,
|
||||||
|
lang_text,
|
||||||
|
is_active,
|
||||||
|
created_date,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
km.key_id,
|
||||||
|
'EN',
|
||||||
|
CASE km.lang_key
|
||||||
|
WHEN 'new.feature.title' THEN 'New Feature'
|
||||||
|
WHEN 'new.feature.description' THEN 'This is a new feature'
|
||||||
|
WHEN 'new.feature.button' THEN 'Get Started'
|
||||||
|
END,
|
||||||
|
'Y',
|
||||||
|
now(),
|
||||||
|
'system'
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
WHERE km.lang_key IN ('new.feature.title', 'new.feature.description', 'new.feature.button')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 문제 해결
|
||||||
|
|
||||||
|
### 1. 일반적인 문제들
|
||||||
|
|
||||||
|
#### 문제: 키가 그대로 표시됨
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 문제 상황
|
||||||
|
<h1>menu.management.title</h1>;
|
||||||
|
|
||||||
|
// 원인: 번역 데이터가 로드되지 않음
|
||||||
|
// 해결: 다국어 텍스트 로드 확인
|
||||||
|
console.log("uiTexts:", uiTexts);
|
||||||
|
console.log("userLang:", userLang);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 문제: 일부만 번역됨
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 원인: 일부 키만 번역 데이터에 등록됨
|
||||||
|
// 해결: 모든 키에 대한 번역 데이터 확인
|
||||||
|
SELECT km.lang_key, t.lang_code, t.lang_text
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
LEFT JOIN multi_lang_text t ON km.key_id = t.key_id
|
||||||
|
WHERE km.lang_key IN ('key1', 'key2', 'key3')
|
||||||
|
AND km.company_code = '*';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 문제: 언어 변경이 반영되지 않음
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 원인: useMultiLang 훅의 의존성 배열 문제
|
||||||
|
// 해결: userLang 변경 시 다국어 텍스트 재로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (userLang) {
|
||||||
|
loadTexts();
|
||||||
|
}
|
||||||
|
}, [userLang]); // userLang 의존성 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 디버깅 방법
|
||||||
|
|
||||||
|
#### 콘솔 로그 확인
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 다국어 텍스트 로드 과정 추적
|
||||||
|
console.log("🌐 다국어 텍스트 로드 시작:", {
|
||||||
|
userLang,
|
||||||
|
keys: MENU_KEYS,
|
||||||
|
uiTextsCount: Object.keys(uiTexts).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 응답 확인
|
||||||
|
console.log("📡 API 응답:", response.data);
|
||||||
|
|
||||||
|
// 최종 상태 확인
|
||||||
|
console.log("✅ 최종 uiTexts:", uiTexts);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 네트워크 탭 확인
|
||||||
|
|
||||||
|
- **API 호출**: `/multilang/batch` 엔드포인트 호출 확인
|
||||||
|
- **요청 파라미터**: langKeys, companyCode, menuCode, userLang 확인
|
||||||
|
- **응답 데이터**: 번역된 텍스트가 올바르게 반환되는지 확인
|
||||||
|
|
||||||
|
#### 데이터베이스 직접 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 특정 키의 번역 데이터 확인
|
||||||
|
SELECT km.lang_key, t.lang_code, t.lang_text, km.description
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
LEFT JOIN multi_lang_text t ON km.key_id = t.key_id
|
||||||
|
WHERE km.lang_key = 'button.add'
|
||||||
|
AND km.company_code = '*'
|
||||||
|
AND t.lang_code = 'KR';
|
||||||
|
|
||||||
|
-- 특정 회사의 모든 번역 데이터 확인
|
||||||
|
SELECT km.lang_key, t.lang_code, t.lang_text, km.description
|
||||||
|
FROM multi_lang_key_master km
|
||||||
|
LEFT JOIN multi_lang_text t ON km.key_id = t.key_id
|
||||||
|
WHERE km.company_code = '*'
|
||||||
|
AND t.lang_code = 'KR';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 모범 사례
|
||||||
|
|
||||||
|
### 1. 키 명명 규칙
|
||||||
|
|
||||||
|
#### 계층 구조 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 좋은 예시
|
||||||
|
export const KEYS = {
|
||||||
|
// 페이지 레벨
|
||||||
|
PAGE_TITLE: "page.title",
|
||||||
|
PAGE_DESCRIPTION: "page.description",
|
||||||
|
|
||||||
|
// 폼 레벨
|
||||||
|
FORM_TITLE: "form.title",
|
||||||
|
FORM_NAME: "form.name",
|
||||||
|
FORM_EMAIL: "form.email",
|
||||||
|
|
||||||
|
// 버튼 레벨
|
||||||
|
BUTTON_SUBMIT: "button.submit",
|
||||||
|
BUTTON_CANCEL: "button.cancel",
|
||||||
|
|
||||||
|
// 메시지 레벨
|
||||||
|
MESSAGE_SUCCESS: "message.success",
|
||||||
|
MESSAGE_ERROR: "message.error",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 일관된 접두사 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 일관된 접두사로 그룹화
|
||||||
|
export const KEYS = {
|
||||||
|
// 공통 UI 요소
|
||||||
|
COMMON_LOADING: "common.loading",
|
||||||
|
COMMON_ERROR: "common.error",
|
||||||
|
COMMON_SUCCESS: "common.success",
|
||||||
|
|
||||||
|
// 특정 기능
|
||||||
|
FEATURE_TITLE: "feature.title",
|
||||||
|
FEATURE_DESCRIPTION: "feature.description",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 성능 최적화
|
||||||
|
|
||||||
|
#### 배치 처리 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 나쁜 예시: 개별 요청
|
||||||
|
const text1 = await getText("key1");
|
||||||
|
const text2 = await getText("key2");
|
||||||
|
const text3 = await getText("key3");
|
||||||
|
|
||||||
|
// ✅ 좋은 예시: 배치 요청
|
||||||
|
const keys = ["key1", "key2", "key3"];
|
||||||
|
const translations = await getBatchTexts(keys);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 캐싱 활용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 번역 결과를 상태에 저장하여 재사용
|
||||||
|
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 한 번 로드한 후 재사용
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(uiTexts).length === 0) {
|
||||||
|
loadTexts();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 에러 처리
|
||||||
|
|
||||||
|
#### 폴백 시스템 구현
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getText = (key: string, fallback?: string) => {
|
||||||
|
// 1. 번역된 텍스트 우선
|
||||||
|
if (uiTexts[key]) return uiTexts[key];
|
||||||
|
|
||||||
|
// 2. 폴백 텍스트 사용
|
||||||
|
if (fallback) return fallback;
|
||||||
|
|
||||||
|
// 3. 키 자체 반환 (디버깅용)
|
||||||
|
console.warn(`번역 키를 찾을 수 없음: ${key}`);
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 로딩 상태 관리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadTexts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await apiClient.post("/multilang/batch", { ... });
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setUiTexts(response.data.data);
|
||||||
|
} else {
|
||||||
|
setError(response.data.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('다국어 텍스트 로드 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 요약
|
||||||
|
|
||||||
|
### 핵심 포인트
|
||||||
|
|
||||||
|
1. **useMultiLang 훅**을 사용하여 사용자 언어 설정 관리
|
||||||
|
2. **배치 API**를 사용하여 여러 키를 한 번에 조회
|
||||||
|
3. **키 상수**를 정의하여 일관성 유지
|
||||||
|
4. **폴백 시스템**을 구현하여 번역 실패 시에도 UI 표시
|
||||||
|
5. **캐싱**을 활용하여 성능 최적화
|
||||||
|
|
||||||
|
### 개발 워크플로우
|
||||||
|
|
||||||
|
1. 다국어 키 상수 정의
|
||||||
|
2. 컴포넌트에서 다국어 텍스트 로드
|
||||||
|
3. UI에 번역된 텍스트 적용
|
||||||
|
4. 데이터베이스에 번역 데이터 등록
|
||||||
|
5. 테스트 및 디버깅
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
|
||||||
|
- 키 명명 규칙을 일관되게 유지
|
||||||
|
- 배치 처리를 사용하여 API 호출 최소화
|
||||||
|
- 폴백 시스템을 구현하여 사용자 경험 보장
|
||||||
|
- 성능을 고려한 캐싱 전략 수립
|
||||||
|
|
||||||
|
이 가이드를 따라하면 새로운 개발자도 쉽게 다국어 시스템을 이해하고 적용할 수 있을 것입니다.
|
||||||
|
|
@ -313,17 +313,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||||
}
|
}
|
||||||
}, [userLang]);
|
}, [userLang]);
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 강제로 번역 로드 (userLang이 아직 설정되지 않았을 수 있음)
|
// 컴포넌트 마운트 시 userLang이 설정될 때까지 대기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
if (userLang) {
|
||||||
if (!userLang) {
|
console.log("🔄 userLang 설정됨, 번역 로드 시작:", userLang);
|
||||||
console.log("🔄 Admin Layout 마운트 후 강제 번역 로드 (userLang 없음)");
|
loadTranslations();
|
||||||
loadTranslations();
|
}
|
||||||
}
|
}, [userLang]); // userLang이 설정될 때마다 실행
|
||||||
}, 100); // 100ms 후 실행
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []); // 컴포넌트 마운트 시 한 번만 실행
|
|
||||||
|
|
||||||
// 키보드 단축키로 사이드바 토글
|
// 키보드 단축키로 사이드바 토글
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -359,11 +355,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||||
|
|
||||||
const loadTranslations = async () => {
|
const loadTranslations = async () => {
|
||||||
try {
|
try {
|
||||||
// 현재 사용자 언어 사용
|
// userLang이 설정되지 않았으면 번역 로드하지 않음
|
||||||
const currentUserLang = userLang || "en";
|
if (!userLang) {
|
||||||
|
console.log("⏳ userLang이 설정되지 않음, 번역 로드 대기");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🌐 Admin Layout 번역 로드 시작", {
|
console.log("🌐 Admin Layout 번역 로드 시작", {
|
||||||
userLang,
|
userLang,
|
||||||
currentUserLang,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API 직접 호출로 현재 언어 사용 (배치 조회 방식)
|
// API 직접 호출로 현재 언어 사용 (배치 조회 방식)
|
||||||
|
|
@ -380,7 +379,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||||
params: {
|
params: {
|
||||||
companyCode,
|
companyCode,
|
||||||
menuCode: "MENU_MANAGEMENT",
|
menuCode: "MENU_MANAGEMENT",
|
||||||
userLang: currentUserLang,
|
userLang: userLang,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -392,24 +391,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||||
translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다.";
|
translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다.";
|
||||||
|
|
||||||
// 번역 캐시에 저장
|
// 번역 캐시에 저장
|
||||||
setTranslationCache(currentUserLang, translations);
|
setTranslationCache(userLang, translations);
|
||||||
|
|
||||||
// 상태 업데이트
|
// 상태 업데이트
|
||||||
setMenuTranslations({ title, description });
|
setMenuTranslations({ title, description });
|
||||||
|
|
||||||
console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang: currentUserLang });
|
console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang });
|
||||||
} else {
|
} else {
|
||||||
// 기본값 사용
|
// 전역 사용자 로케일 확인하여 기본값 설정
|
||||||
const title = "메뉴 관리";
|
const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
|
||||||
const description = "시스템의 메뉴 구조와 권한을 관리합니다.";
|
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 });
|
setMenuTranslations({ title, description });
|
||||||
console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: currentUserLang });
|
console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: globalUserLang });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Admin Layout 배치 번역 로드 실패:", error);
|
console.error("❌ Admin Layout 배치 번역 로드 실패:", error);
|
||||||
// 오류 시 기본값 사용
|
// 오류 시에도 전역 사용자 로케일 확인하여 기본값 설정
|
||||||
const title = "메뉴 관리";
|
const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
|
||||||
const description = "시스템의 메뉴 구조와 권한을 관리합니다.";
|
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 });
|
setMenuTranslations({ title, description });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -510,11 +530,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-4 text-2xl font-bold text-red-600">인증 실패</h1>
|
<h1 className="mb-4 text-2xl font-bold text-red-600">인증 실패</h1>
|
||||||
<p className="mb-4">토큰이 없습니다. 3초 후 로그인 페이지로 이동합니다.</p>
|
<p className="mb-4">토큰이 없습니다. 3초 후 로그인 페이지로 이동합니다.</p>
|
||||||
<div className="rounded bg-yellow-100 p-4">
|
|
||||||
<h2 className="mb-2 font-semibold">디버깅 정보</h2>
|
|
||||||
<p>현재 경로: {pathname}</p>
|
|
||||||
<p>토큰: {localStorage.getItem("authToken") ? "존재" : "없음"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getMenuTextSync, MENU_MANAGEMENT_KEYS, setTranslationCache } from "@/lib/utils/multilang";
|
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
|
@ -27,6 +27,8 @@ interface MenuFormModalProps {
|
||||||
menuType?: string;
|
menuType?: string;
|
||||||
level?: number;
|
level?: number;
|
||||||
parentCompanyCode?: string;
|
parentCompanyCode?: string;
|
||||||
|
// 다국어 텍스트 props 추가
|
||||||
|
uiTexts: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
@ -38,6 +40,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
menuType,
|
menuType,
|
||||||
level,
|
level,
|
||||||
parentCompanyCode,
|
parentCompanyCode,
|
||||||
|
uiTexts,
|
||||||
}) => {
|
}) => {
|
||||||
console.log("🎯 MenuFormModal 렌더링 - Props:", {
|
console.log("🎯 MenuFormModal 렌더링 - Props:", {
|
||||||
isOpen,
|
isOpen,
|
||||||
|
|
@ -48,6 +51,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
parentCompanyCode,
|
parentCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 다국어 텍스트 가져오기 함수
|
||||||
|
const getText = (key: string, fallback?: string): string => {
|
||||||
|
return uiTexts[key] || fallback || key;
|
||||||
|
};
|
||||||
|
|
||||||
console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
||||||
|
|
||||||
const [formData, setFormData] = useState<MenuFormData>({
|
const [formData, setFormData] = useState<MenuFormData>({
|
||||||
|
|
@ -149,7 +157,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
response: error?.response,
|
response: error?.response,
|
||||||
});
|
});
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +262,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setCompanies(companyList);
|
setCompanies(companyList);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("회사 목록 로딩 오류:", error);
|
console.error("회사 목록 로딩 오류:", error);
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -273,7 +281,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 다국어 키 목록 로딩 오류:", error);
|
console.error("❌ 다국어 키 목록 로딩 오류:", error);
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST));
|
||||||
setLangKeys([]);
|
setLangKeys([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -282,12 +290,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!formData.menuNameKor.trim()) {
|
if (!formData.menuNameKor.trim()) {
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.companyCode) {
|
if (!formData.companyCode) {
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,7 +332,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메뉴 저장/수정 실패:", error);
|
console.error("메뉴 저장/수정 실패:", error);
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -345,58 +353,63 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
||||||
const selectedLangKeyInfo = getSelectedLangKeyInfo();
|
const selectedLangKeyInfo = getSelectedLangKeyInfo();
|
||||||
|
|
||||||
|
// 전역 사용자 로케일 가져오기
|
||||||
|
const getCurrentUserLang = () => {
|
||||||
|
return (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isEdit
|
{isEdit
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
|
? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
|
||||||
: getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
|
: getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="menuType">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE)}</Label>
|
<Label htmlFor="menuType">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE)}</Label>
|
||||||
<Select value={formData.menuType} onValueChange={(value) => handleInputChange("menuType", value)}>
|
<Select value={formData.menuType} onValueChange={(value) => handleInputChange("menuType", value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN)}</SelectItem>
|
<SelectItem value="0">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN)}</SelectItem>
|
||||||
<SelectItem value="1">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER)}</SelectItem>
|
<SelectItem value="1">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER)}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="status">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS)}</Label>
|
<Label htmlFor="status">{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}</Label>
|
||||||
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
|
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ACTIVE">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_ACTIVE)}</SelectItem>
|
<SelectItem value="ACTIVE">{getText(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)}</SelectItem>
|
||||||
<SelectItem value="INACTIVE">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_INACTIVE)}</SelectItem>
|
<SelectItem value="INACTIVE">{getText(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="companyCode">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY)} *</Label>
|
<Label htmlFor="companyCode">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY)} *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.companyCode}
|
value={formData.companyCode}
|
||||||
onValueChange={(value) => handleInputChange("companyCode", value)}
|
onValueChange={(value) => handleInputChange("companyCode", value)}
|
||||||
disabled={!isEdit && level !== 1} // 수정 모드가 아니고 최상위 메뉴가 아니면 비활성화
|
disabled={!isEdit && level !== 1} // 수정 모드가 아니고 최상위 메뉴가 아니면 비활성화
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT)} />
|
<SelectValue placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT)} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON)}</SelectItem>
|
<SelectItem value="none">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON)}</SelectItem>
|
||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<SelectItem key={company.company_code} value={company.company_code}>
|
<SelectItem key={company.company_code} value={company.company_code}>
|
||||||
{company.company_name}
|
{company.company_name}
|
||||||
|
|
@ -405,12 +418,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{!isEdit && level !== 1 && (
|
{!isEdit && level !== 1 && (
|
||||||
<p className="text-xs text-gray-500">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
|
<p className="text-xs text-gray-500">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="langKey">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY)}</Label>
|
<Label htmlFor="langKey">{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY)}</Label>
|
||||||
<div className="langkey-dropdown relative">
|
<div className="langkey-dropdown relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -419,7 +432,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
disabled={!formData.companyCode}
|
disabled={!formData.companyCode}
|
||||||
>
|
>
|
||||||
<span className={!formData.langKey ? "text-muted-foreground" : ""}>
|
<span className={!formData.langKey ? "text-muted-foreground" : ""}>
|
||||||
{formData.langKey || getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT)}
|
{formData.langKey || getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT)}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isLangKeyDropdownOpen ? "rotate-180" : ""}`}
|
className={`h-4 w-4 transition-transform ${isLangKeyDropdownOpen ? "rotate-180" : ""}`}
|
||||||
|
|
@ -436,7 +449,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
{/* 검색 입력 */}
|
{/* 검색 입력 */}
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH)}
|
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH)}
|
||||||
value={langKeySearchText}
|
value={langKeySearchText}
|
||||||
onChange={(e) => setLangKeySearchText(e.target.value)}
|
onChange={(e) => setLangKeySearchText(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
|
|
@ -454,7 +467,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setLangKeySearchText("");
|
setLangKeySearchText("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE)}
|
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{langKeys
|
{langKeys
|
||||||
|
|
@ -483,48 +496,47 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{selectedLangKeyInfo && (
|
{selectedLangKeyInfo && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED, {
|
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED)
|
||||||
key: selectedLangKeyInfo.langKey,
|
.replace("{key}", selectedLangKeyInfo.langKey)
|
||||||
description: selectedLangKeyInfo.description,
|
.replace("{description}", selectedLangKeyInfo.description)}
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="menuNameKor">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME)} *</Label>
|
<Label htmlFor="menuNameKor">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME)} *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="menuNameKor"
|
id="menuNameKor"
|
||||||
value={formData.menuNameKor}
|
value={formData.menuNameKor}
|
||||||
onChange={(e) => handleInputChange("menuNameKor", e.target.value)}
|
onChange={(e) => handleInputChange("menuNameKor", e.target.value)}
|
||||||
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)}
|
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="menuUrl">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="menuUrl"
|
id="menuUrl"
|
||||||
value={formData.menuUrl}
|
value={formData.menuUrl}
|
||||||
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
|
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
|
||||||
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
|
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="menuDesc">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION)}</Label>
|
<Label htmlFor="menuDesc">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION)}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="menuDesc"
|
id="menuDesc"
|
||||||
value={formData.menuDesc}
|
value={formData.menuDesc}
|
||||||
onChange={(e) => handleInputChange("menuDesc", e.target.value)}
|
onChange={(e) => handleInputChange("menuDesc", e.target.value)}
|
||||||
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER)}
|
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER)}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="seq">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE)}</Label>
|
<Label htmlFor="seq">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE)}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="seq"
|
id="seq"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -536,14 +548,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL)}
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading
|
{loading
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING)
|
? getText(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING)
|
||||||
: isEdit
|
: isEdit
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY)
|
? getText(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY)
|
||||||
: getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER)}
|
: getText(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { menuApi } from "@/lib/api/menu";
|
import { menuApi } from "@/lib/api/menu";
|
||||||
import type { MenuItem } from "@/lib/api/menu";
|
import type { MenuItem } from "@/lib/api/menu";
|
||||||
import { MenuTable } from "./MenuTable";
|
import { MenuTable } from "./MenuTable";
|
||||||
|
|
@ -24,12 +24,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import {
|
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
|
||||||
getMenuTextSync,
|
|
||||||
MENU_MANAGEMENT_KEYS,
|
|
||||||
useMenuManagementText,
|
|
||||||
setTranslationCache,
|
|
||||||
} from "@/lib/utils/multilang";
|
|
||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
|
@ -46,7 +41,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 다국어 텍스트 훅 사용
|
// 다국어 텍스트 훅 사용
|
||||||
const { getMenuText } = useMenuManagementText();
|
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
|
||||||
const { userLang } = useMultiLang({ companyCode: "*" });
|
const { userLang } = useMultiLang({ companyCode: "*" });
|
||||||
|
|
||||||
// 다국어 텍스트 상태
|
// 다국어 텍스트 상태
|
||||||
|
|
@ -68,29 +63,282 @@ export const MenuManagement: React.FC = () => {
|
||||||
parentCompanyCode: "",
|
parentCompanyCode: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
|
||||||
|
|
||||||
|
// 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
|
||||||
|
const MENU_MANAGEMENT_LANG_KEYS = [
|
||||||
|
// 페이지 제목 및 설명
|
||||||
|
"menu.management.title",
|
||||||
|
"menu.management.description",
|
||||||
|
"menu.type.title",
|
||||||
|
"menu.type.admin",
|
||||||
|
"menu.type.user",
|
||||||
|
"menu.management.admin",
|
||||||
|
"menu.management.user",
|
||||||
|
"menu.management.admin.description",
|
||||||
|
"menu.management.user.description",
|
||||||
|
|
||||||
|
// 버튼
|
||||||
|
"button.add",
|
||||||
|
"button.add.top.level",
|
||||||
|
"button.add.sub",
|
||||||
|
"button.edit",
|
||||||
|
"button.delete",
|
||||||
|
"button.delete.selected",
|
||||||
|
"button.delete.selected.count",
|
||||||
|
"button.delete.processing",
|
||||||
|
"button.cancel",
|
||||||
|
"button.save",
|
||||||
|
"button.register",
|
||||||
|
"button.modify",
|
||||||
|
|
||||||
|
// 필터 및 검색
|
||||||
|
"filter.company",
|
||||||
|
"filter.company.all",
|
||||||
|
"filter.company.common",
|
||||||
|
"filter.company.search",
|
||||||
|
"filter.search",
|
||||||
|
"filter.search.placeholder",
|
||||||
|
"filter.reset",
|
||||||
|
|
||||||
|
// 테이블 헤더
|
||||||
|
"table.header.select",
|
||||||
|
"table.header.menu.name",
|
||||||
|
"table.header.menu.url",
|
||||||
|
"table.header.menu.type",
|
||||||
|
"table.header.status",
|
||||||
|
"table.header.company",
|
||||||
|
"table.header.sequence",
|
||||||
|
"table.header.actions",
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
"status.active",
|
||||||
|
"status.inactive",
|
||||||
|
"status.unspecified",
|
||||||
|
|
||||||
|
// 폼
|
||||||
|
"form.menu.type",
|
||||||
|
"form.menu.type.admin",
|
||||||
|
"form.menu.type.user",
|
||||||
|
"form.company",
|
||||||
|
"form.company.select",
|
||||||
|
"form.company.common",
|
||||||
|
"form.company.submenu.note",
|
||||||
|
"form.lang.key",
|
||||||
|
"form.lang.key.select",
|
||||||
|
"form.lang.key.none",
|
||||||
|
"form.lang.key.search",
|
||||||
|
"form.lang.key.selected",
|
||||||
|
"form.menu.name",
|
||||||
|
"form.menu.name.placeholder",
|
||||||
|
"form.menu.url",
|
||||||
|
"form.menu.url.placeholder",
|
||||||
|
"form.menu.description",
|
||||||
|
"form.menu.description.placeholder",
|
||||||
|
"form.menu.sequence",
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
"modal.menu.register.title",
|
||||||
|
"modal.menu.modify.title",
|
||||||
|
"modal.delete.title",
|
||||||
|
"modal.delete.description",
|
||||||
|
"modal.delete.batch.description",
|
||||||
|
|
||||||
|
// 메시지
|
||||||
|
"message.loading",
|
||||||
|
"message.menu.delete.processing",
|
||||||
|
"message.menu.save.success",
|
||||||
|
"message.menu.save.failed",
|
||||||
|
"message.menu.delete.success",
|
||||||
|
"message.menu.delete.failed",
|
||||||
|
"message.menu.delete.batch.success",
|
||||||
|
"message.menu.delete.batch.partial",
|
||||||
|
"message.menu.status.toggle.success",
|
||||||
|
"message.menu.status.toggle.failed",
|
||||||
|
"message.validation.menu.name.required",
|
||||||
|
"message.validation.company.required",
|
||||||
|
"message.validation.select.menu.delete",
|
||||||
|
"message.error.load.menu.list",
|
||||||
|
"message.error.load.menu.info",
|
||||||
|
"message.error.load.company.list",
|
||||||
|
"message.error.load.lang.key.list",
|
||||||
|
|
||||||
|
// 리스트 정보
|
||||||
|
"menu.list.title",
|
||||||
|
"menu.list.total",
|
||||||
|
"menu.list.search.result",
|
||||||
|
|
||||||
|
// UI
|
||||||
|
"ui.expand",
|
||||||
|
"ui.collapse",
|
||||||
|
"ui.menu.collapse",
|
||||||
|
"ui.language",
|
||||||
|
];
|
||||||
|
|
||||||
// 초기 로딩
|
// 초기 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCompanies();
|
loadCompanies();
|
||||||
}, []); // 빈 의존성 배열로 한 번만 실행
|
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
|
||||||
|
if (!userLang) {
|
||||||
|
initializeDefaultTexts();
|
||||||
|
}
|
||||||
|
}, [userLang]); // userLang 변경 시마다 실행
|
||||||
|
|
||||||
|
// 초기 기본 텍스트 설정 함수
|
||||||
|
const initializeDefaultTexts = () => {
|
||||||
|
const defaultTexts: Record<string, string> = {};
|
||||||
|
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||||
|
// 기본 한국어 텍스트 제공
|
||||||
|
const defaultText = getDefaultText(key);
|
||||||
|
defaultTexts[key] = defaultText;
|
||||||
|
});
|
||||||
|
setUiTexts(defaultTexts);
|
||||||
|
console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 텍스트 반환 함수
|
||||||
|
const getDefaultText = (key: string): string => {
|
||||||
|
const defaultTexts: Record<string, string> = {
|
||||||
|
"menu.management.title": "메뉴 관리",
|
||||||
|
"menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
|
||||||
|
"menu.type.title": "메뉴 타입",
|
||||||
|
"menu.type.admin": "관리자",
|
||||||
|
"menu.type.user": "사용자",
|
||||||
|
"menu.management.admin": "관리자 메뉴",
|
||||||
|
"menu.management.user": "사용자 메뉴",
|
||||||
|
"menu.management.admin.description": "시스템 관리 및 설정 메뉴",
|
||||||
|
"menu.management.user.description": "일반 사용자 업무 메뉴",
|
||||||
|
"button.add": "추가",
|
||||||
|
"button.add.top.level": "최상위 메뉴 추가",
|
||||||
|
"button.add.sub": "하위 메뉴 추가",
|
||||||
|
"button.edit": "수정",
|
||||||
|
"button.delete": "삭제",
|
||||||
|
"button.delete.selected": "선택 삭제",
|
||||||
|
"button.delete.selected.count": "선택 삭제 ({count})",
|
||||||
|
"button.delete.processing": "삭제 중...",
|
||||||
|
"button.cancel": "취소",
|
||||||
|
"button.save": "저장",
|
||||||
|
"button.register": "등록",
|
||||||
|
"button.modify": "수정",
|
||||||
|
"filter.company": "회사",
|
||||||
|
"filter.company.all": "전체",
|
||||||
|
"filter.company.common": "공통",
|
||||||
|
"filter.company.search": "회사 검색",
|
||||||
|
"filter.search": "검색",
|
||||||
|
"filter.search.placeholder": "메뉴명 또는 URL로 검색...",
|
||||||
|
"filter.reset": "초기화",
|
||||||
|
"table.header.select": "선택",
|
||||||
|
"table.header.menu.name": "메뉴명",
|
||||||
|
"table.header.menu.url": "URL",
|
||||||
|
"table.header.menu.type": "메뉴 타입",
|
||||||
|
"table.header.status": "상태",
|
||||||
|
"table.header.company": "회사",
|
||||||
|
"table.header.sequence": "순서",
|
||||||
|
"table.header.actions": "작업",
|
||||||
|
"status.active": "활성화",
|
||||||
|
"status.inactive": "비활성화",
|
||||||
|
"status.unspecified": "미지정",
|
||||||
|
"form.menu.type": "메뉴 타입",
|
||||||
|
"form.menu.type.admin": "관리자",
|
||||||
|
"form.menu.type.user": "사용자",
|
||||||
|
"form.company": "회사",
|
||||||
|
"form.company.select": "회사를 선택하세요",
|
||||||
|
"form.company.common": "공통",
|
||||||
|
"form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
|
||||||
|
"form.lang.key": "다국어 키",
|
||||||
|
"form.lang.key.select": "다국어 키를 선택하세요",
|
||||||
|
"form.lang.key.none": "다국어 키 없음",
|
||||||
|
"form.lang.key.search": "다국어 키 검색...",
|
||||||
|
"form.lang.key.selected": "선택된 키: {key} - {description}",
|
||||||
|
"form.menu.name": "메뉴명",
|
||||||
|
"form.menu.name.placeholder": "메뉴명을 입력하세요",
|
||||||
|
"form.menu.url": "URL",
|
||||||
|
"form.menu.url.placeholder": "메뉴 URL을 입력하세요",
|
||||||
|
"form.menu.description": "설명",
|
||||||
|
"form.menu.description.placeholder": "메뉴 설명을 입력하세요",
|
||||||
|
"form.menu.sequence": "순서",
|
||||||
|
"modal.menu.register.title": "메뉴 등록",
|
||||||
|
"modal.menu.modify.title": "메뉴 수정",
|
||||||
|
"modal.delete.title": "메뉴 삭제",
|
||||||
|
"modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"modal.delete.batch.description":
|
||||||
|
"선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"message.loading": "로딩 중...",
|
||||||
|
"message.menu.delete.processing": "메뉴 삭제 중...",
|
||||||
|
"message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
|
||||||
|
"message.menu.save.failed": "메뉴 저장에 실패했습니다.",
|
||||||
|
"message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
|
||||||
|
"message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
|
||||||
|
"message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
|
||||||
|
"message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
|
||||||
|
"message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
|
||||||
|
"message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
|
||||||
|
"message.validation.menu.name.required": "메뉴명을 입력해주세요.",
|
||||||
|
"message.validation.company.required": "회사를 선택해주세요.",
|
||||||
|
"message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
|
||||||
|
"message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
|
||||||
|
"message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
|
||||||
|
"message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
|
||||||
|
"message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
|
||||||
|
"menu.list.title": "메뉴 목록",
|
||||||
|
"menu.list.total": "총 {count}개",
|
||||||
|
"menu.list.search.result": "검색 결과: {count}개",
|
||||||
|
"ui.expand": "펼치기",
|
||||||
|
"ui.collapse": "접기",
|
||||||
|
"ui.menu.collapse": "메뉴 접기",
|
||||||
|
"ui.language": "언어",
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaultTexts[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
|
// 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uiTextsLoading) {
|
if (userLang && !uiTextsLoading) {
|
||||||
loadUITexts();
|
loadUITexts();
|
||||||
}
|
}
|
||||||
}, [userLang]); // userLang 변경 시마다 실행
|
}, [userLang]); // userLang 변경 시마다 실행
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 강제로 번역 로드 (userLang이 아직 설정되지 않았을 수 있음)
|
// uiTexts 상태 변경 감지
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔄 uiTexts 상태 변경됨:", {
|
||||||
|
count: Object.keys(uiTexts).length,
|
||||||
|
sampleKeys: Object.keys(uiTexts).slice(0, 5),
|
||||||
|
sampleValues: Object.entries(uiTexts)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`),
|
||||||
|
});
|
||||||
|
}, [uiTexts]);
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
|
if (userLang && !uiTextsLoading) {
|
||||||
console.log("🔄 컴포넌트 마운트 후 강제 번역 로드");
|
console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
|
||||||
loadUITexts();
|
loadUITexts();
|
||||||
}
|
}
|
||||||
}, 100); // 100ms 후 실행
|
}, 300); // 300ms 후 실행
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []); // 컴포넌트 마운트 시 한 번만 실행
|
}, [userLang]); // userLang이 설정된 후 실행
|
||||||
|
|
||||||
|
// 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const fallbackTimer = setTimeout(() => {
|
||||||
|
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
|
||||||
|
console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
|
||||||
|
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
|
||||||
|
if (!userLang) {
|
||||||
|
initializeDefaultTexts();
|
||||||
|
} else {
|
||||||
|
// 사용자 언어가 설정된 경우 다국어 텍스트 로드
|
||||||
|
loadUITexts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000); // 1초 후 실행
|
||||||
|
|
||||||
|
return () => clearTimeout(fallbackTimer);
|
||||||
|
}, [userLang]); // userLang 변경 시마다 실행
|
||||||
|
|
||||||
// 번역 로드 이벤트 감지
|
// 번역 로드 이벤트 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -134,10 +382,10 @@ export const MenuManagement: React.FC = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
await refreshMenus();
|
await refreshMenus();
|
||||||
console.log(`📋 메뉴 목록 조회 성공`);
|
console.log("📋 메뉴 목록 조회 성공");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 메뉴 목록 조회 실패:", error);
|
console.error("❌ 메뉴 목록 조회 실패:", error);
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_LIST));
|
toast.error(getUITextSync("message.error.load.menu.list"));
|
||||||
} finally {
|
} finally {
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -147,7 +395,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
console.log(`🏢 회사 목록 조회 시작`);
|
console.log("🏢 회사 목록 조회 시작");
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/admin/companies");
|
const response = await apiClient.get("/admin/companies");
|
||||||
|
|
||||||
|
|
@ -165,228 +413,94 @@ export const MenuManagement: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다국어 텍스트 로드 함수
|
// 다국어 텍스트 로드 함수 - 배치 API 사용
|
||||||
const loadUITexts = async () => {
|
const loadUITexts = async () => {
|
||||||
if (uiTextsLoading) return; // 이미 로딩 중이면 중단
|
if (uiTextsLoading) return; // 이미 로딩 중이면 중단
|
||||||
|
|
||||||
// userLang이 없으면 기본값 사용
|
// userLang이 설정되지 않았으면 기본값 설정
|
||||||
const currentUserLang = userLang || "KR";
|
if (!userLang) {
|
||||||
console.log("🌐 UI 다국어 텍스트 로드 시작", { currentUserLang });
|
console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
|
||||||
|
const defaultTexts: Record<string, string> = {};
|
||||||
|
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||||
|
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
|
||||||
|
});
|
||||||
|
setUiTexts(defaultTexts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
|
||||||
|
if (Object.keys(uiTexts).length === 0) {
|
||||||
|
console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
|
||||||
|
const defaultTexts: Record<string, string> = {};
|
||||||
|
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||||
|
defaultTexts[key] = getDefaultText(key);
|
||||||
|
});
|
||||||
|
setUiTexts(defaultTexts);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🌐 UI 다국어 텍스트 로드 시작", {
|
||||||
|
userLang,
|
||||||
|
apiParams: {
|
||||||
|
companyCode: "*",
|
||||||
|
menuCode: "menu.management",
|
||||||
|
userLang: userLang,
|
||||||
|
},
|
||||||
|
});
|
||||||
setUiTextsLoading(true);
|
setUiTextsLoading(true);
|
||||||
|
|
||||||
const texts: Record<string, string> = {};
|
|
||||||
try {
|
try {
|
||||||
const textPromises = [
|
// 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TITLE),
|
const response = await apiClient.post(
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.DESCRIPTION),
|
"/multilang/batch",
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE),
|
{
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN),
|
langKeys: MENU_MANAGEMENT_LANG_KEYS,
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_USER),
|
companyCode: "*", // 모든 회사
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_MENU),
|
menuCode: "menu.management", // 메뉴관리 메뉴
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.USER_MENU),
|
userLang: userLang, // body에 포함
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION),
|
},
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.USER_DESCRIPTION),
|
{
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD),
|
params: {}, // query params는 비움
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL),
|
},
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB),
|
);
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.FILTER_RESET),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.LIST_TOTAL),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_TYPE),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE),
|
|
||||||
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS),
|
|
||||||
// 추가 키들 - 실제 UI에서 사용되는 모든 키들
|
|
||||||
getMenuText("menu.list.title"),
|
|
||||||
getMenuText("filter.company"),
|
|
||||||
getMenuText("filter.company.all"),
|
|
||||||
getMenuText("filter.search"),
|
|
||||||
getMenuText("filter.search.placeholder"),
|
|
||||||
getMenuText("status.unspecified"),
|
|
||||||
getMenuText("status.active"),
|
|
||||||
getMenuText("filter.company.common"),
|
|
||||||
getMenuText("modal.menu.register.title"),
|
|
||||||
getMenuText("form.menu.type"),
|
|
||||||
getMenuText("form.menu.type.admin"),
|
|
||||||
getMenuText("form.menu.type.user"),
|
|
||||||
getMenuText("form.status"),
|
|
||||||
getMenuText("form.status.active"),
|
|
||||||
getMenuText("form.status.inactive"),
|
|
||||||
getMenuText("form.company"),
|
|
||||||
getMenuText("form.company.select"),
|
|
||||||
getMenuText("form.company.common"),
|
|
||||||
getMenuText("form.company.submenu.note"),
|
|
||||||
getMenuText("form.lang.key"),
|
|
||||||
getMenuText("form.lang.key.select"),
|
|
||||||
getMenuText("form.menu.name"),
|
|
||||||
getMenuText("form.menu.name.placeholder"),
|
|
||||||
getMenuText("form.menu.url"),
|
|
||||||
getMenuText("form.menu.url.placeholder"),
|
|
||||||
getMenuText("form.menu.description"),
|
|
||||||
getMenuText("form.menu.description.placeholder"),
|
|
||||||
getMenuText("form.menu.sequence"),
|
|
||||||
getMenuText("button.cancel"),
|
|
||||||
getMenuText("button.register"),
|
|
||||||
// 테이블 헤더 관련 추가 키들
|
|
||||||
getMenuText("table.header.menu.name"),
|
|
||||||
getMenuText("table.header.sequence"),
|
|
||||||
getMenuText("table.header.company"),
|
|
||||||
getMenuText("table.header.menu.url"),
|
|
||||||
getMenuText("table.header.status"),
|
|
||||||
getMenuText("table.header.actions"),
|
|
||||||
// 액션 버튼 관련 추가 키들
|
|
||||||
getMenuText("button.add"),
|
|
||||||
getMenuText("button.add.sub"),
|
|
||||||
getMenuText("button.edit"),
|
|
||||||
getMenuText("button.delete"),
|
|
||||||
// 페이지 제목 관련
|
|
||||||
getMenuText("page.title.menu.management"),
|
|
||||||
getMenuText("page.description.menu.management"),
|
|
||||||
getMenuText("section.title.menu.type"),
|
|
||||||
getMenuText("section.title.admin.menu.list"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.all(textPromises);
|
if (response.data.success) {
|
||||||
|
const translations = response.data.data;
|
||||||
|
console.log("🌐 배치 다국어 텍스트 응답:", translations);
|
||||||
|
|
||||||
// 결과를 키와 매핑
|
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
|
||||||
const keys = [
|
const mergedTranslations = { ...uiTexts, ...translations };
|
||||||
MENU_MANAGEMENT_KEYS.TITLE,
|
console.log("🔧 setUiTexts 호출 전:", {
|
||||||
MENU_MANAGEMENT_KEYS.DESCRIPTION,
|
translationsCount: Object.keys(translations).length,
|
||||||
MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE,
|
mergedCount: Object.keys(mergedTranslations).length,
|
||||||
MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN,
|
});
|
||||||
MENU_MANAGEMENT_KEYS.MENU_TYPE_USER,
|
setUiTexts(mergedTranslations);
|
||||||
MENU_MANAGEMENT_KEYS.ADMIN_MENU,
|
console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
|
||||||
MENU_MANAGEMENT_KEYS.USER_MENU,
|
|
||||||
MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION,
|
|
||||||
MENU_MANAGEMENT_KEYS.USER_DESCRIPTION,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_ADD,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_EDIT,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_DELETE,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT,
|
|
||||||
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING,
|
|
||||||
MENU_MANAGEMENT_KEYS.FILTER_RESET,
|
|
||||||
MENU_MANAGEMENT_KEYS.LIST_TOTAL,
|
|
||||||
MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_TYPE,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE,
|
|
||||||
MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS,
|
|
||||||
// 추가 키들 - 실제 UI에서 사용되는 모든 키들
|
|
||||||
"menu.list.title",
|
|
||||||
"filter.company",
|
|
||||||
"filter.company.all",
|
|
||||||
"filter.search",
|
|
||||||
"filter.search.placeholder",
|
|
||||||
"status.unspecified",
|
|
||||||
"status.active",
|
|
||||||
"filter.company.common",
|
|
||||||
"modal.menu.register.title",
|
|
||||||
"form.menu.type",
|
|
||||||
"form.menu.type.admin",
|
|
||||||
"form.menu.type.user",
|
|
||||||
"form.status",
|
|
||||||
"form.status.active",
|
|
||||||
"form.status.inactive",
|
|
||||||
"form.company",
|
|
||||||
"form.company.select",
|
|
||||||
"form.company.common",
|
|
||||||
"form.company.submenu.note",
|
|
||||||
"form.lang.key",
|
|
||||||
"form.lang.key.select",
|
|
||||||
"form.menu.name",
|
|
||||||
"form.menu.name.placeholder",
|
|
||||||
"form.menu.url",
|
|
||||||
"form.menu.url.placeholder",
|
|
||||||
"form.menu.description",
|
|
||||||
"form.menu.description.placeholder",
|
|
||||||
"form.menu.sequence",
|
|
||||||
"button.cancel",
|
|
||||||
"button.register",
|
|
||||||
// 테이블 헤더 관련 추가 키들
|
|
||||||
"table.header.menu.name",
|
|
||||||
"table.header.sequence",
|
|
||||||
"table.header.company",
|
|
||||||
"table.header.menu.url",
|
|
||||||
"table.header.status",
|
|
||||||
"table.header.actions",
|
|
||||||
// 액션 버튼 관련 추가 키들
|
|
||||||
"button.add",
|
|
||||||
"button.add.sub",
|
|
||||||
"button.edit",
|
|
||||||
"button.delete",
|
|
||||||
// 페이지 제목 관련
|
|
||||||
"page.title.menu.management",
|
|
||||||
"page.description.menu.management",
|
|
||||||
"section.title.menu.type",
|
|
||||||
"section.title.admin.menu.list",
|
|
||||||
];
|
|
||||||
|
|
||||||
keys.forEach((key, index) => {
|
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
|
||||||
texts[key] = results[index];
|
setTranslationCache(userLang, mergedTranslations);
|
||||||
});
|
} else {
|
||||||
|
console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
|
||||||
setUiTexts(texts);
|
// API 실패 시에도 기존 uiTexts는 유지
|
||||||
|
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||||
// 번역 텍스트를 캐시에 저장
|
}
|
||||||
setTranslationCache(currentUserLang, texts);
|
|
||||||
|
|
||||||
console.log("🌐 UI 다국어 텍스트 로드 완료:", texts);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
|
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
|
||||||
|
// API 실패 시에도 기존 uiTexts는 유지
|
||||||
|
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||||
} finally {
|
} finally {
|
||||||
setUiTextsLoading(false);
|
setUiTextsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// UI 텍스트 가져오기 함수
|
// UI 텍스트 가져오기 함수 (동기 버전만 사용)
|
||||||
const getUIText = async (
|
// getUIText 함수는 제거 - getUITextSync만 사용
|
||||||
key: string,
|
|
||||||
params?: Record<string, string | number>,
|
|
||||||
fallback?: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
// uiTexts에서 먼저 찾기
|
|
||||||
let text = uiTexts[key];
|
|
||||||
|
|
||||||
// uiTexts에 없으면 비동기적으로 API 호출
|
// 동기 버전 (DB에서 가져온 번역 텍스트 사용)
|
||||||
if (!text) {
|
|
||||||
try {
|
|
||||||
text = await getMenuText(key);
|
|
||||||
// 새로운 텍스트를 uiTexts에 추가
|
|
||||||
setUiTexts((prev) => ({ ...prev, [key]: text }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ 키 "${key}" 번역 실패:`, error);
|
|
||||||
text = fallback || key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 파라미터 치환
|
|
||||||
if (params && text) {
|
|
||||||
Object.entries(params).forEach(([paramKey, paramValue]) => {
|
|
||||||
text = text!.replace(`{${paramKey}}`, String(paramValue));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return text || key;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 동기 버전 (기존 호환성을 위해)
|
|
||||||
const getUITextSync = (key: string, params?: Record<string, string | number>, fallback?: string): string => {
|
const getUITextSync = (key: string, params?: Record<string, string | number>, fallback?: string): string => {
|
||||||
|
// uiTexts에서 번역 텍스트 찾기
|
||||||
let text = uiTexts[key];
|
let text = uiTexts[key];
|
||||||
|
|
||||||
|
// uiTexts에 없으면 fallback 또는 키 사용
|
||||||
if (!text) {
|
if (!text) {
|
||||||
text = fallback || key;
|
text = fallback || key;
|
||||||
}
|
}
|
||||||
|
|
@ -401,11 +515,11 @@ export const MenuManagement: React.FC = () => {
|
||||||
return text || key;
|
return text || key;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다국어 API 테스트 함수
|
// 다국어 API 테스트 함수 (getUITextSync 사용)
|
||||||
const testMultiLangAPI = async () => {
|
const testMultiLangAPI = async () => {
|
||||||
console.log("🧪 다국어 API 테스트 시작");
|
console.log("🧪 다국어 API 테스트 시작");
|
||||||
try {
|
try {
|
||||||
const text = await getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_MENU);
|
const text = getUITextSync("menu.management.admin");
|
||||||
console.log("🧪 다국어 API 테스트 결과:", text);
|
console.log("🧪 다국어 API 테스트 결과:", text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 다국어 API 테스트 실패:", error);
|
console.error("❌ 다국어 API 테스트 실패:", error);
|
||||||
|
|
@ -513,11 +627,11 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
const handleDeleteSelectedMenus = async () => {
|
const handleDeleteSelectedMenus = async () => {
|
||||||
if (selectedMenus.size === 0) {
|
if (selectedMenus.size === 0) {
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_SELECT_MENU_DELETE));
|
toast.error(getUITextSync("message.validation.select.menu.delete"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_DELETE_BATCH_DESCRIPTION, { count: selectedMenus.size }))) {
|
if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,7 +640,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
const menuIds = Array.from(selectedMenus);
|
const menuIds = Array.from(selectedMenus);
|
||||||
console.log("삭제할 메뉴 IDs:", menuIds);
|
console.log("삭제할 메뉴 IDs:", menuIds);
|
||||||
|
|
||||||
toast.info(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_PROCESSING));
|
toast.info(getUITextSync("message.menu.delete.processing"));
|
||||||
|
|
||||||
const response = await menuApi.deleteMenusBatch(menuIds);
|
const response = await menuApi.deleteMenusBatch(menuIds);
|
||||||
console.log("삭제 API 응답:", response);
|
console.log("삭제 API 응답:", response);
|
||||||
|
|
@ -552,12 +666,10 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// 삭제 결과 메시지
|
// 삭제 결과 메시지
|
||||||
if (failedCount === 0) {
|
if (failedCount === 0) {
|
||||||
toast.success(
|
toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
|
||||||
getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_SUCCESS, { count: deletedCount }),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
toast.success(
|
toast.success(
|
||||||
getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_PARTIAL, {
|
getUITextSync("message.menu.delete.batch.partial", {
|
||||||
success: deletedCount,
|
success: deletedCount,
|
||||||
failed: failedCount,
|
failed: failedCount,
|
||||||
}),
|
}),
|
||||||
|
|
@ -569,7 +681,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메뉴 삭제 중 오류:", error);
|
console.error("메뉴 삭제 중 오류:", error);
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_FAILED));
|
toast.error(getUITextSync("message.menu.delete.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +717,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메뉴 상태 토글 오류:", error);
|
console.error("메뉴 상태 토글 오류:", error);
|
||||||
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_FAILED));
|
toast.error(getUITextSync("message.menu.status.toggle.failed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -658,15 +770,29 @@ export const MenuManagement: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMenuTypeString = () => {
|
const getMenuTypeString = () => {
|
||||||
return selectedMenuType === "admin"
|
return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN)
|
|
||||||
: getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_USER);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMenuTypeValue = () => {
|
const getMenuTypeValue = () => {
|
||||||
return selectedMenuType === "admin" ? "0" : "1";
|
return selectedMenuType === "admin" ? "0" : "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
|
||||||
|
const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
|
||||||
|
const adminMenusCount = useMemo(() => adminMenus?.length || 0, [adminMenus]);
|
||||||
|
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
|
||||||
|
|
||||||
|
// 디버깅을 위한 간단한 상태 표시
|
||||||
|
console.log("🔍 MenuManagement 렌더링 상태:", {
|
||||||
|
loading,
|
||||||
|
uiTextsLoading,
|
||||||
|
uiTextsCount,
|
||||||
|
adminMenusCount,
|
||||||
|
userMenusCount,
|
||||||
|
selectedMenuType,
|
||||||
|
userLang,
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
|
@ -676,14 +802,14 @@ export const MenuManagement: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingOverlay isLoading={deleting} text="메뉴 삭제 중...">
|
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 메인 컨텐츠 - 2:8 비율 */}
|
{/* 메인 컨텐츠 - 2:8 비율 */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
|
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
|
||||||
<div className="w-[20%] border-r bg-gray-50">
|
<div className="w-[20%] border-r bg-gray-50">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h2 className="mb-4 text-lg font-semibold">{getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE)}</h2>
|
<h2 className="mb-4 text-lg font-semibold">{getUITextSync("menu.type.title")}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Card
|
<Card
|
||||||
className={`cursor-pointer transition-all ${
|
className={`cursor-pointer transition-all ${
|
||||||
|
|
@ -694,9 +820,9 @@ export const MenuManagement: React.FC = () => {
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">{getUITextSync(MENU_MANAGEMENT_KEYS.ADMIN_MENU)}</h3>
|
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION)}
|
{getUITextSync("menu.management.admin.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
|
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
|
||||||
|
|
@ -715,9 +841,9 @@ export const MenuManagement: React.FC = () => {
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">{getUITextSync(MENU_MANAGEMENT_KEYS.USER_MENU)}</h3>
|
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.USER_DESCRIPTION)}
|
{getUITextSync("menu.management.user.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>{userMenus.length}</Badge>
|
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>{userMenus.length}</Badge>
|
||||||
|
|
@ -733,7 +859,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
<div className="flex h-full flex-col p-6">
|
<div className="flex h-full flex-col p-6">
|
||||||
<div className="mb-6 flex-shrink-0">
|
<div className="mb-6 flex-shrink-0">
|
||||||
<h2 className="mb-2 text-xl font-semibold">
|
<h2 className="mb-2 text-xl font-semibold">
|
||||||
{getMenuTypeString()} {getMenuTextSync(MENU_MANAGEMENT_KEYS.LIST_TITLE)}
|
{getMenuTypeString()} {getUITextSync("menu.list.title")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -741,7 +867,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
<div className="mb-4 flex-shrink-0">
|
<div className="mb-4 flex-shrink-0">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="company">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY)}</Label>
|
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
|
||||||
<div className="company-dropdown relative">
|
<div className="company-dropdown relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -750,11 +876,11 @@ export const MenuManagement: React.FC = () => {
|
||||||
>
|
>
|
||||||
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
|
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
|
||||||
{selectedCompany === "all"
|
{selectedCompany === "all"
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)
|
? getUITextSync("filter.company.all")
|
||||||
: selectedCompany === "*"
|
: selectedCompany === "*"
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
|
? getUITextSync("filter.company.common")
|
||||||
: companies.find((c) => c.code === selectedCompany)?.name ||
|
: companies.find((c) => c.code === selectedCompany)?.name ||
|
||||||
getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)}
|
getUITextSync("filter.company.all")}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
|
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
|
||||||
|
|
@ -771,7 +897,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
{/* 검색 입력 */}
|
{/* 검색 입력 */}
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_SEARCH)}
|
placeholder={getUITextSync("filter.company.search")}
|
||||||
value={companySearchText}
|
value={companySearchText}
|
||||||
onChange={(e) => setCompanySearchText(e.target.value)}
|
onChange={(e) => setCompanySearchText(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
|
|
@ -789,7 +915,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
setCompanySearchText("");
|
setCompanySearchText("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)}
|
{getUITextSync("filter.company.all")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||||
|
|
@ -799,7 +925,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
setCompanySearchText("");
|
setCompanySearchText("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)}
|
{getUITextSync("filter.company.common")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{companies
|
{companies
|
||||||
|
|
@ -819,7 +945,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
setCompanySearchText("");
|
setCompanySearchText("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{company.code === "*" ? "공통" : company.name}
|
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -829,9 +955,9 @@ export const MenuManagement: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="search">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_SEARCH)}</Label>
|
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_SEARCH_PLACEHOLDER)}
|
placeholder={getUITextSync("filter.search.placeholder")}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -847,13 +973,13 @@ export const MenuManagement: React.FC = () => {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.FILTER_RESET)}
|
{getUITextSync("filter.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT, { count: getCurrentMenus().length })}
|
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -862,11 +988,11 @@ export const MenuManagement: React.FC = () => {
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.LIST_TOTAL, { count: getCurrentMenus().length })}
|
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
|
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL)}
|
{getUITextSync("button.add.top.level")}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedMenus.size > 0 && (
|
{selectedMenus.size > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -878,10 +1004,10 @@ export const MenuManagement: React.FC = () => {
|
||||||
{deleting ? (
|
{deleting ? (
|
||||||
<>
|
<>
|
||||||
<LoadingSpinner size="sm" className="mr-2" />
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
{getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING)}
|
{getUITextSync("button.delete.processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT, {
|
getUITextSync("button.delete.selected.count", {
|
||||||
count: selectedMenus.size,
|
count: selectedMenus.size,
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
@ -900,6 +1026,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
onSelectAllMenus={handleSelectAllMenus}
|
onSelectAllMenus={handleSelectAllMenus}
|
||||||
expandedMenus={expandedMenus}
|
expandedMenus={expandedMenus}
|
||||||
onToggleExpand={handleToggleExpand}
|
onToggleExpand={handleToggleExpand}
|
||||||
|
uiTexts={uiTexts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -915,6 +1042,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
menuType={formData.menuType}
|
menuType={formData.menuType}
|
||||||
level={formData.level}
|
level={formData.level}
|
||||||
parentCompanyCode={formData.parentCompanyCode}
|
parentCompanyCode={formData.parentCompanyCode}
|
||||||
|
uiTexts={uiTexts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
|
||||||
import { getMenuTextSync, MENU_MANAGEMENT_KEYS, setTranslationCache } from "@/lib/utils/multilang";
|
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
|
||||||
|
|
||||||
interface MenuTableProps {
|
interface MenuTableProps {
|
||||||
menus: MenuItem[];
|
menus: MenuItem[];
|
||||||
|
|
@ -20,6 +20,8 @@ interface MenuTableProps {
|
||||||
onSelectAllMenus: (checked: boolean) => void;
|
onSelectAllMenus: (checked: boolean) => void;
|
||||||
expandedMenus: Set<string>;
|
expandedMenus: Set<string>;
|
||||||
onToggleExpand: (menuId: string) => void;
|
onToggleExpand: (menuId: string) => void;
|
||||||
|
// 다국어 텍스트 props 추가
|
||||||
|
uiTexts: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuTable: React.FC<MenuTableProps> = ({
|
export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
|
|
@ -33,8 +35,12 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
onSelectAllMenus,
|
onSelectAllMenus,
|
||||||
expandedMenus,
|
expandedMenus,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
|
uiTexts,
|
||||||
}) => {
|
}) => {
|
||||||
const { userLang } = useMultiLang();
|
// 다국어 텍스트 가져오기 함수
|
||||||
|
const getText = (key: string, fallback?: string): string => {
|
||||||
|
return uiTexts[key] || fallback || key;
|
||||||
|
};
|
||||||
|
|
||||||
// 다국어 텍스트 표시 함수 (기본값 처리)
|
// 다국어 텍스트 표시 함수 (기본값 처리)
|
||||||
const getDisplayText = (menu: MenuItem) => {
|
const getDisplayText = (menu: MenuItem) => {
|
||||||
|
|
@ -130,8 +136,8 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status === "active"
|
{status === "active"
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)
|
? getText(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)
|
||||||
: getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}
|
: getText(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -156,22 +162,22 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-1/3 bg-gray-50 font-semibold text-gray-700">
|
<TableHead className="w-1/3 bg-gray-50 font-semibold text-gray-700">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME)}
|
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-16 bg-gray-50 font-semibold text-gray-700">
|
<TableHead className="w-16 bg-gray-50 font-semibold text-gray-700">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE)}
|
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-24 bg-gray-50 font-semibold text-gray-700">
|
<TableHead className="w-24 bg-gray-50 font-semibold text-gray-700">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY)}
|
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-48 bg-gray-50 font-semibold text-gray-700">
|
<TableHead className="w-48 bg-gray-50 font-semibold text-gray-700">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL)}
|
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-20 bg-gray-50 font-semibold text-gray-700">
|
<TableHead className="w-20 bg-gray-50 font-semibold text-gray-700">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}
|
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-32 bg-gray-50 font-semibold text-gray-700">
|
<TableHead className="w-32 bg-gray-50 font-semibold text-gray-700">
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS)}
|
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -236,11 +242,11 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
<TableCell className="text-sm text-gray-600">
|
<TableCell className="text-sm text-gray-600">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span
|
<span
|
||||||
className={`font-medium ${companyName && companyName !== getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED) ? "text-green-600" : "text-gray-500"}`}
|
className={`font-medium ${companyName && companyName !== getText(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED) ? "text-green-600" : "text-gray-500"}`}
|
||||||
>
|
>
|
||||||
{companyCode === "*"
|
{companyCode === "*"
|
||||||
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
|
? getText(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
|
||||||
: companyName || getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED)}
|
: companyName || getText(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED)}
|
||||||
</span>
|
</span>
|
||||||
{companyCode && companyCode !== "" && (
|
{companyCode && companyCode !== "" && (
|
||||||
<span className="font-mono text-xs text-gray-400">{companyCode}</span>
|
<span className="font-mono text-xs text-gray-400">{companyCode}</span>
|
||||||
|
|
@ -285,7 +291,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
className="min-w-[40px] px-1 py-1 text-xs"
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{lev === 2 && (
|
{lev === 2 && (
|
||||||
|
|
@ -296,7 +302,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
className="min-w-[40px] px-1 py-1 text-xs"
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB)}
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -304,7 +310,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
className="min-w-[40px] px-1 py-1 text-xs"
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
onClick={() => onEditMenu(objid)}
|
onClick={() => onEditMenu(objid)}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -315,7 +321,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
className="min-w-[40px] px-1 py-1 text-xs"
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
onClick={() => onEditMenu(objid)}
|
onClick={() => onEditMenu(objid)}
|
||||||
>
|
>
|
||||||
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,34 @@ export const useAuth = () => {
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
console.log("사용자 정보 조회 성공:", response.data);
|
console.log("사용자 정보 조회 성공:", response.data);
|
||||||
|
|
||||||
|
// 사용자 로케일 정보도 함께 조회하여 전역 저장
|
||||||
|
try {
|
||||||
|
const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
|
||||||
|
if (localeResponse.success && localeResponse.data) {
|
||||||
|
const userLocale = localeResponse.data;
|
||||||
|
console.log("✅ 사용자 로케일 조회 성공:", userLocale);
|
||||||
|
|
||||||
|
// 전역 상태에 저장 (다른 컴포넌트에서 사용)
|
||||||
|
(window as any).__GLOBAL_USER_LANG = userLocale;
|
||||||
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||||
|
|
||||||
|
// localStorage에도 저장 (새 창에서 공유)
|
||||||
|
localStorage.setItem("userLocale", userLocale);
|
||||||
|
localStorage.setItem("userLocaleLoaded", "true");
|
||||||
|
|
||||||
|
console.log("🌐 전역 사용자 로케일 저장됨:", userLocale);
|
||||||
|
}
|
||||||
|
} catch (localeError) {
|
||||||
|
console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError);
|
||||||
|
(window as any).__GLOBAL_USER_LANG = "KR";
|
||||||
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||||
|
|
||||||
|
// localStorage에도 저장
|
||||||
|
localStorage.setItem("userLocale", "KR");
|
||||||
|
localStorage.setItem("userLocaleLoaded", "true");
|
||||||
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +376,12 @@ export const useAuth = () => {
|
||||||
// JWT 토큰 제거
|
// JWT 토큰 제거
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
|
|
||||||
|
// 로케일 정보도 제거
|
||||||
|
localStorage.removeItem("userLocale");
|
||||||
|
localStorage.removeItem("userLocaleLoaded");
|
||||||
|
(window as any).__GLOBAL_USER_LANG = undefined;
|
||||||
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
||||||
|
|
||||||
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
|
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
|
|
@ -365,6 +399,13 @@ export const useAuth = () => {
|
||||||
|
|
||||||
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
|
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
|
|
||||||
|
// 로케일 정보도 제거
|
||||||
|
localStorage.removeItem("userLocale");
|
||||||
|
localStorage.removeItem("userLocaleLoaded");
|
||||||
|
(window as any).__GLOBAL_USER_LANG = undefined;
|
||||||
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
|
|
||||||
|
|
@ -6,75 +6,87 @@ let globalUserLang = "KR";
|
||||||
let globalChangeLangCallback: ((lang: string) => void) | null = null;
|
let globalChangeLangCallback: ((lang: string) => void) | null = null;
|
||||||
|
|
||||||
export const useMultiLang = (options: { companyCode?: string } = {}) => {
|
export const useMultiLang = (options: { companyCode?: string } = {}) => {
|
||||||
const [userLang, setUserLang] = useState<string>("KR");
|
const [userLang, setUserLang] = useState<string | null>(null); // null로 시작
|
||||||
const companyCode = options.companyCode || "*";
|
const companyCode = options.companyCode || "*";
|
||||||
|
|
||||||
// 전역 언어 상태 동기화
|
// 전역 언어 상태 동기화 (무한 루프 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (globalUserLang !== userLang) {
|
// 초기 로딩 시에만 동기화
|
||||||
|
if (globalUserLang && globalUserLang !== userLang) {
|
||||||
setUserLang(globalUserLang);
|
setUserLang(globalUserLang);
|
||||||
}
|
}
|
||||||
}, [globalUserLang]);
|
}, []); // 의존성 배열을 비워서 한 번만 실행
|
||||||
|
|
||||||
// 언어 변경 시 전역 콜백 호출
|
// 언어 변경 시 전역 콜백 호출 (무한 루프 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (globalChangeLangCallback) {
|
// 언어가 설정된 경우에만 콜백 호출
|
||||||
|
if (globalChangeLangCallback && userLang) {
|
||||||
globalChangeLangCallback(userLang);
|
globalChangeLangCallback(userLang);
|
||||||
}
|
}
|
||||||
}, [userLang]);
|
}, [userLang]);
|
||||||
|
|
||||||
// 사용자 로케일 조회 (한 번만 실행)
|
// 사용자 로케일 조회 (한 번만 실행)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// localStorage에서 로케일 확인 (새 창에서도 공유)
|
||||||
|
const storedLocale = localStorage.getItem("userLocale");
|
||||||
|
const storedLocaleLoaded = localStorage.getItem("userLocaleLoaded");
|
||||||
|
|
||||||
|
if (storedLocaleLoaded === "true" && storedLocale) {
|
||||||
|
console.log("🌐 localStorage에서 사용자 로케일 사용:", storedLocale);
|
||||||
|
setUserLang(storedLocale);
|
||||||
|
globalUserLang = storedLocale;
|
||||||
|
|
||||||
|
// 전역 상태도 동기화
|
||||||
|
(window as any).__GLOBAL_USER_LANG = storedLocale;
|
||||||
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역에서 이미 로케일이 로드되었는지 확인
|
||||||
|
if ((window as any).__GLOBAL_USER_LOCALE_LOADED) {
|
||||||
|
const globalLocale = (window as any).__GLOBAL_USER_LANG;
|
||||||
|
console.log("🌐 전역에서 사용자 로케일 사용:", globalLocale);
|
||||||
|
setUserLang(globalLocale);
|
||||||
|
globalUserLang = globalLocale;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 이미 로케일이 설정되어 있으면 중복 호출 방지
|
// 이미 로케일이 설정되어 있으면 중복 호출 방지
|
||||||
if (globalUserLang && globalUserLang !== "KR") {
|
if (globalUserLang) {
|
||||||
setUserLang(globalUserLang);
|
setUserLang(globalUserLang);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchUserLocale = async () => {
|
// 전역 로케일이 아직 로드되지 않았으면 대기
|
||||||
try {
|
console.log("⏳ 전역 로케일 로드 대기 중...");
|
||||||
console.log("🔍 사용자 로케일 조회 시작");
|
|
||||||
const response = await apiClient.get("/admin/user-locale");
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
// 주기적으로 전역 로케일 확인 (더 빠른 간격으로)
|
||||||
const userLocale = response.data.data;
|
const checkInterval = setInterval(() => {
|
||||||
console.log("✅ 사용자 로케일 조회 성공:", userLocale);
|
if ((window as any).__GLOBAL_USER_LOCALE_LOADED) {
|
||||||
|
const globalLocale = (window as any).__GLOBAL_USER_LANG;
|
||||||
// 데이터베이스의 locale 값을 그대로 사용 (매핑 없음)
|
console.log("🌐 전역에서 사용자 로케일 확인됨:", globalLocale);
|
||||||
setUserLang(userLocale);
|
setUserLang(globalLocale);
|
||||||
globalUserLang = userLocale; // 전역 상태도 업데이트
|
globalUserLang = globalLocale;
|
||||||
return;
|
clearInterval(checkInterval);
|
||||||
}
|
|
||||||
|
|
||||||
// API 호출 실패 시 브라우저 언어 사용
|
|
||||||
console.warn("⚠️ 사용자 로케일 조회 실패, 브라우저 언어 사용");
|
|
||||||
const browserLang = navigator.language.split("-")[0];
|
|
||||||
|
|
||||||
// 브라우저 언어를 그대로 사용 (매핑 없음)
|
|
||||||
if (["ko", "en", "ja", "zh"].includes(browserLang)) {
|
|
||||||
setUserLang(browserLang);
|
|
||||||
globalUserLang = browserLang;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 사용자 로케일 조회 중 오류:", error);
|
|
||||||
|
|
||||||
// 오류 시 브라우저 언어 사용
|
|
||||||
const browserLang = navigator.language.split("-")[0];
|
|
||||||
|
|
||||||
// 브라우저 언어를 그대로 사용 (매핑 없음)
|
|
||||||
if (["ko", "en", "ja", "zh"].includes(browserLang)) {
|
|
||||||
setUserLang(browserLang);
|
|
||||||
globalUserLang = browserLang;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}, 50); // 50ms로 단축
|
||||||
|
|
||||||
fetchUserLocale();
|
// 3초 후 타임아웃 (더 빠른 타임아웃)
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
if (!userLang) {
|
||||||
|
console.warn("⚠️ 전역 로케일 로드 타임아웃, 기본값 사용");
|
||||||
|
setUserLang("KR");
|
||||||
|
globalUserLang = "KR";
|
||||||
|
}
|
||||||
|
}, 3000); // 3초로 단축
|
||||||
|
|
||||||
|
return () => clearInterval(checkInterval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 다국어 텍스트 가져오기 (배치 조회 방식)
|
// 다국어 텍스트 가져오기 (배치 조회 방식)
|
||||||
const getText = async (menuCode: string, langKey: string, fallback?: string): Promise<string> => {
|
const getText = async (menuCode: string, langKey: string, fallback?: string): Promise<string> => {
|
||||||
console.log(`🔍 다국어 텍스트 요청 (배치 방식):`, { menuCode, langKey, userLang, companyCode });
|
console.log("🔍 다국어 텍스트 요청 (배치 방식):", { menuCode, langKey, userLang, companyCode });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 배치 조회 API 사용
|
// 배치 조회 API 사용
|
||||||
|
|
@ -92,7 +104,7 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📡 배치 API 응답 상태:`, response.status, response.statusText);
|
console.log("📡 배치 API 응답 상태:", response.status, response.statusText);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && response.data.data[langKey]) {
|
if (response.data.success && response.data.data && response.data.data[langKey]) {
|
||||||
// 번역 텍스트를 캐시에 저장
|
// 번역 텍스트를 캐시에 저장
|
||||||
|
|
@ -105,26 +117,36 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실패 시 fallback 또는 키 반환
|
// 실패 시 fallback 또는 키 반환
|
||||||
console.log(`🔄 배치 API 성공했지만 데이터 없음, fallback 반환:`, fallback || langKey);
|
console.log("🔄 배치 API 성공했지만 데이터 없음, fallback 반환:", fallback || langKey);
|
||||||
return fallback || langKey;
|
return fallback || langKey;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 다국어 텍스트 배치 조회 실패:", error);
|
console.error("❌ 다국어 텍스트 배치 조회 실패:", error);
|
||||||
console.log(`🔄 에러 시 fallback 반환:`, fallback || langKey);
|
console.log("🔄 에러 시 fallback 반환:", fallback || langKey);
|
||||||
return fallback || langKey;
|
return fallback || langKey;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 언어 변경
|
// 언어 변경 (무한 루프 방지)
|
||||||
const changeLang = async (newLang: string) => {
|
const changeLang = async (newLang: string) => {
|
||||||
|
// 같은 언어로 변경하려는 경우 무시
|
||||||
|
if (newLang === userLang) {
|
||||||
|
console.log("🔄 같은 언어로 변경 시도 무시:", newLang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("🔄 언어 변경 시작:", { from: userLang, to: newLang });
|
||||||
|
|
||||||
// 백엔드에 사용자 로케일 설정 요청
|
// 백엔드에 사용자 로케일 설정 요청
|
||||||
const response = await apiClient.post("/admin/user-locale", {
|
const response = await apiClient.post("/admin/user-locale", {
|
||||||
locale: newLang,
|
locale: newLang,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setUserLang(newLang);
|
// 전역 상태 먼저 업데이트
|
||||||
globalUserLang = newLang;
|
globalUserLang = newLang;
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setUserLang(newLang);
|
||||||
console.log("✅ 사용자 로케일 변경 성공:", newLang);
|
console.log("✅ 사용자 로케일 변경 성공:", newLang);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 사용자 로케일 변경 실패:", response.data.message);
|
console.error("❌ 사용자 로케일 변경 실패:", response.data.message);
|
||||||
|
|
@ -132,8 +154,8 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 사용자 로케일 변경 중 오류:", error);
|
console.error("❌ 사용자 로케일 변경 중 오류:", error);
|
||||||
// 오류 시에도 로컬 상태는 변경
|
// 오류 시에도 로컬 상태는 변경
|
||||||
setUserLang(newLang);
|
|
||||||
globalUserLang = newLang;
|
globalUserLang = newLang;
|
||||||
|
setUserLang(newLang);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,10 @@ apiClient.interceptors.request.use(
|
||||||
console.warn("⚠️ 토큰이 없습니다.");
|
console.warn("⚠️ 토큰이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 언어 정보를 쿼리 파라미터에 추가
|
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
|
||||||
if (config.method?.toUpperCase() === "GET") {
|
if (config.method?.toUpperCase() === "GET") {
|
||||||
// 전역 언어 상태에서 현재 언어 가져오기
|
// 전역 언어 상태에서 현재 언어 가져오기 (DB 값 그대로 사용)
|
||||||
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "ko" : "ko";
|
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "KR" : "KR";
|
||||||
console.log("🌐 API 요청 시 언어 정보:", currentLang);
|
console.log("🌐 API 요청 시 언어 정보:", currentLang);
|
||||||
|
|
||||||
if (config.params) {
|
if (config.params) {
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,11 @@ export const menuApi = {
|
||||||
menuCode?: string;
|
menuCode?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
}): Promise<ApiResponse<LangKey[]>> => {
|
}): Promise<ApiResponse<LangKey[]>> => {
|
||||||
console.log("🔍 다국어 키 목록 조회 API 호출:", "/admin/multilang/keys", params);
|
console.log("🔍 다국어 키 목록 조회 API 호출:", "/multilang/keys", params);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Node.js 백엔드의 실제 라우팅과 일치하도록 수정
|
// Node.js 백엔드의 실제 라우팅과 일치하도록 수정
|
||||||
const response = await apiClient.get("/admin/multilang/keys", { params });
|
const response = await apiClient.get("/multilang/keys", { params });
|
||||||
console.log("✅ 다국어 키 목록 조회 성공:", response.data);
|
console.log("✅ 다국어 키 목록 조회 성공:", response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { apiClient } from "../api/client";
|
||||||
// 메뉴 관리 화면 다국어 키 상수
|
// 메뉴 관리 화면 다국어 키 상수
|
||||||
export const MENU_MANAGEMENT_KEYS = {
|
export const MENU_MANAGEMENT_KEYS = {
|
||||||
// 기본 정보
|
// 기본 정보
|
||||||
TITLE: "title",
|
TITLE: "menu.management.title",
|
||||||
DESCRIPTION: "description",
|
DESCRIPTION: "menu.management.description",
|
||||||
MENU_TYPE_TITLE: "menu.type.title",
|
MENU_TYPE_TITLE: "menu.type.title",
|
||||||
MENU_TYPE_ADMIN: "menu.type.admin",
|
MENU_TYPE_ADMIN: "menu.type.admin",
|
||||||
MENU_TYPE_USER: "menu.type.user",
|
MENU_TYPE_USER: "menu.type.user",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue