메뉴관리 다국어 중간 커밋

This commit is contained in:
kjs 2025-08-25 17:22:20 +09:00
parent 0e89393a14
commit 307faba089
9 changed files with 485 additions and 365 deletions

View File

@ -763,12 +763,22 @@ export const getBatchTranslations = async (
): Promise<void> => {
try {
const { companyCode, menuCode, userLang } = req.query;
const { langKeys } = req.body;
const {
langKeys,
companyCode: bodyCompanyCode,
menuCode: bodyMenuCode,
userLang: bodyUserLang,
} = req.body;
// query params에서 읽지 못한 경우 body에서 읽기
const finalCompanyCode = companyCode || bodyCompanyCode;
const finalMenuCode = menuCode || bodyMenuCode;
const finalUserLang = userLang || bodyUserLang;
logger.info("다국어 텍스트 배치 조회 요청", {
companyCode,
menuCode,
userLang,
companyCode: finalCompanyCode,
menuCode: finalMenuCode,
userLang: finalUserLang,
keyCount: langKeys?.length || 0,
user: req.user,
});
@ -785,7 +795,7 @@ export const getBatchTranslations = async (
return;
}
if (!companyCode || !userLang) {
if (!finalCompanyCode || !finalUserLang) {
res.status(400).json({
success: false,
message: "companyCode와 userLang은 필수입니다.",
@ -809,9 +819,9 @@ export const getBatchTranslations = async (
try {
const multiLangService = new MultiLangService(client);
const translations = await multiLangService.getBatchTranslations({
companyCode: companyCode as string,
menuCode: menuCode as string,
userLang: userLang as string,
companyCode: finalCompanyCode as string,
menuCode: finalMenuCode as string,
userLang: finalUserLang as string,
langKeys,
});

View File

@ -313,17 +313,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}
}, [userLang]);
// 컴포넌트 마운트 시 강제로 번역 로드 (userLang이 아직 설정되지 않았을 수 있음)
// 컴포넌트 마운트 시 userLang이 설정될 때까지 대기
useEffect(() => {
const timer = setTimeout(() => {
if (!userLang) {
console.log("🔄 Admin Layout 마운트 후 강제 번역 로드 (userLang 없음)");
loadTranslations();
}
}, 100); // 100ms 후 실행
return () => clearTimeout(timer);
}, []); // 컴포넌트 마운트 시 한 번만 실행
if (userLang) {
console.log("🔄 userLang 설정됨, 번역 로드 시작:", userLang);
loadTranslations();
}
}, [userLang]); // userLang이 설정될 때마다 실행
// 키보드 단축키로 사이드바 토글
useEffect(() => {
@ -359,11 +355,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const loadTranslations = async () => {
try {
// 현재 사용자 언어 사용
const currentUserLang = userLang || "en";
// userLang이 설정되지 않았으면 번역 로드하지 않음
if (!userLang) {
console.log("⏳ userLang이 설정되지 않음, 번역 로드 대기");
return;
}
console.log("🌐 Admin Layout 번역 로드 시작", {
userLang,
currentUserLang,
});
// API 직접 호출로 현재 언어 사용 (배치 조회 방식)
@ -380,7 +379,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
params: {
companyCode,
menuCode: "MENU_MANAGEMENT",
userLang: currentUserLang,
userLang: userLang,
},
},
);
@ -392,24 +391,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다.";
// 번역 캐시에 저장
setTranslationCache(currentUserLang, translations);
setTranslationCache(userLang, translations);
// 상태 업데이트
setMenuTranslations({ title, description });
console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang: currentUserLang });
console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang });
} else {
// 기본값 사용
const title = "메뉴 관리";
const description = "시스템의 메뉴 구조와 권한을 관리합니다.";
// 전역 사용자 로케일 확인하여 기본값 설정
const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
console.log("🌐 전역 사용자 로케일 확인:", globalUserLang);
// 사용자 로케일에 따른 기본값 설정
let title, description;
if (globalUserLang === "US") {
title = "Menu Management";
description = "Manage system menu structure and permissions";
} else {
title = "메뉴 관리";
description = "시스템의 메뉴 구조와 권한을 관리합니다.";
}
setMenuTranslations({ title, description });
console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: currentUserLang });
console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: globalUserLang });
}
} catch (error) {
console.error("❌ Admin Layout 배치 번역 로드 실패:", error);
// 오류 시 기본값 사용
const title = "메뉴 관리";
const description = "시스템의 메뉴 구조와 권한을 관리합니다.";
// 오류 시에도 전역 사용자 로케일 확인하여 기본값 설정
const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
console.log("🌐 오류 시 전역 사용자 로케일 확인:", globalUserLang);
let title, description;
if (globalUserLang === "US") {
title = "Menu Management";
description = "Manage system menu structure and permissions";
} else {
title = "메뉴 관리";
description = "시스템의 메뉴 구조와 권한을 관리합니다.";
}
setMenuTranslations({ title, description });
}
} catch (error) {
@ -510,11 +530,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="text-center">
<h1 className="mb-4 text-2xl font-bold text-red-600"> </h1>
<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>
)}

View File

@ -345,58 +345,79 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const selectedLangKeyInfo = getSelectedLangKeyInfo();
// 전역 사용자 로케일 가져오기
const getCurrentUserLang = () => {
return (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{isEdit
? getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
: getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
? getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE, getCurrentUserLang())
: getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE, getCurrentUserLang())}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="menuType">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE)}</Label>
<Label htmlFor="menuType">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE, getCurrentUserLang())}
</Label>
<Select value={formData.menuType} onValueChange={(value) => handleInputChange("menuType", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN)}</SelectItem>
<SelectItem value="1">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER)}</SelectItem>
<SelectItem value="0">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN, getCurrentUserLang())}
</SelectItem>
<SelectItem value="1">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER, getCurrentUserLang())}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS)}</Label>
<Label htmlFor="status">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS, getCurrentUserLang())}</Label>
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_ACTIVE)}</SelectItem>
<SelectItem value="INACTIVE">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_INACTIVE)}</SelectItem>
<SelectItem value="ACTIVE">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_ACTIVE, getCurrentUserLang())}
</SelectItem>
<SelectItem value="INACTIVE">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_INACTIVE, getCurrentUserLang())}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="companyCode">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY)} *</Label>
<Label htmlFor="companyCode">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY, getCurrentUserLang())} *
</Label>
<Select
value={formData.companyCode}
onValueChange={(value) => handleInputChange("companyCode", value)}
disabled={!isEdit && level !== 1} // 수정 모드가 아니고 최상위 메뉴가 아니면 비활성화
>
<SelectTrigger>
<SelectValue placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT)} />
<SelectValue
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT, getCurrentUserLang())}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON)}</SelectItem>
<SelectItem value="none">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON, getCurrentUserLang())}
</SelectItem>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
@ -405,12 +426,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</SelectContent>
</Select>
{!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">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE, getCurrentUserLang())}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="langKey">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY)}</Label>
<Label htmlFor="langKey">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY, getCurrentUserLang())}</Label>
<div className="langkey-dropdown relative">
<button
type="button"
@ -419,7 +442,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
disabled={!formData.companyCode}
>
<span className={!formData.langKey ? "text-muted-foreground" : ""}>
{formData.langKey || getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT)}
{formData.langKey || getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT, getCurrentUserLang())}
</span>
<svg
className={`h-4 w-4 transition-transform ${isLangKeyDropdownOpen ? "rotate-180" : ""}`}
@ -436,7 +459,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
{/* 검색 입력 */}
<div className="border-b p-2">
<Input
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH, getCurrentUserLang())}
value={langKeySearchText}
onChange={(e) => setLangKeySearchText(e.target.value)}
className="h-8 text-sm"
@ -454,7 +477,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setLangKeySearchText("");
}}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE)}
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE, getCurrentUserLang())}
</div>
{langKeys
@ -492,39 +515,48 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</div>
<div className="space-y-2">
<Label htmlFor="menuNameKor">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME)} *</Label>
<Label htmlFor="menuNameKor">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME, getCurrentUserLang())} *
</Label>
<Input
id="menuNameKor"
value={formData.menuNameKor}
onChange={(e) => handleInputChange("menuNameKor", e.target.value)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER, getCurrentUserLang())}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="menuUrl">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
<Label htmlFor="menuUrl">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL, getCurrentUserLang())}</Label>
<Input
id="menuUrl"
value={formData.menuUrl}
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER, getCurrentUserLang())}
/>
</div>
<div className="space-y-2">
<Label htmlFor="menuDesc">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION)}</Label>
<Label htmlFor="menuDesc">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION, getCurrentUserLang())}
</Label>
<Textarea
id="menuDesc"
value={formData.menuDesc}
onChange={(e) => handleInputChange("menuDesc", e.target.value)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER)}
placeholder={getMenuTextSync(
MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER,
getCurrentUserLang(),
)}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="seq">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE)}</Label>
<Label htmlFor="seq">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE, getCurrentUserLang())}
</Label>
<Input
id="seq"
type="number"
@ -536,14 +568,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL)}
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL, getCurrentUserLang())}
</Button>
<Button type="submit" disabled={loading}>
{loading
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING)
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING, getCurrentUserLang())
: isEdit
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY)
: getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER)}
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY, getCurrentUserLang())
: getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER, getCurrentUserLang())}
</Button>
</div>
</form>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { menuApi } from "@/lib/api/menu";
import type { MenuItem } from "@/lib/api/menu";
import { MenuTable } from "./MenuTable";
@ -24,12 +24,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useMenu } from "@/contexts/MenuContext";
import {
getMenuTextSync,
MENU_MANAGEMENT_KEYS,
useMenuManagementText,
setTranslationCache,
} from "@/lib/utils/multilang";
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client";
@ -46,7 +41,7 @@ export const MenuManagement: React.FC = () => {
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
// 다국어 텍스트 훅 사용
const { getMenuText } = useMenuManagementText();
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
const { userLang } = useMultiLang({ companyCode: "*" });
// 다국어 텍스트 상태
@ -68,6 +63,119 @@ export const MenuManagement: React.FC = () => {
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.status",
"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(() => {
loadCompanies();
@ -80,6 +188,17 @@ export const MenuManagement: React.FC = () => {
}
}, [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(() => {
const timer = setTimeout(() => {
@ -134,10 +253,10 @@ export const MenuManagement: React.FC = () => {
setLoading(true);
}
await refreshMenus();
console.log(`📋 메뉴 목록 조회 성공`);
console.log("📋 메뉴 목록 조회 성공");
} catch (error) {
console.error("❌ 메뉴 목록 조회 실패:", error);
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_LIST));
toast.error(getUITextSync("message.error.load.menu.list"));
} finally {
if (showLoading) {
setLoading(false);
@ -147,7 +266,7 @@ export const MenuManagement: React.FC = () => {
// 회사 목록 조회
const loadCompanies = async () => {
console.log(`🏢 회사 목록 조회 시작`);
console.log("🏢 회사 목록 조회 시작");
try {
const response = await apiClient.get("/admin/companies");
@ -165,230 +284,99 @@ export const MenuManagement: React.FC = () => {
}
};
// 다국어 텍스트 로드 함수
// 다국어 텍스트 로드 함수 - 배치 API 사용
const loadUITexts = async () => {
if (uiTextsLoading) return; // 이미 로딩 중이면 중단
// userLang이 없으면 기본값 사용
const currentUserLang = userLang || "KR";
console.log("🌐 UI 다국어 텍스트 로드 시작", { currentUserLang });
// userLang이 설정되지 않았으면 기본값 설정
if (!userLang) {
console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = key; // 키를 기본값으로 사용
});
setUiTexts(defaultTexts);
return;
}
console.log("🌐 UI 다국어 텍스트 로드 시작", {
userLang,
apiParams: {
companyCode: "*",
menuCode: "menu.management",
userLang: userLang,
},
});
setUiTextsLoading(true);
const texts: Record<string, string> = {};
try {
const textPromises = [
getMenuText(MENU_MANAGEMENT_KEYS.TITLE),
getMenuText(MENU_MANAGEMENT_KEYS.DESCRIPTION),
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE),
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN),
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_USER),
getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_MENU),
getMenuText(MENU_MANAGEMENT_KEYS.USER_MENU),
getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION),
getMenuText(MENU_MANAGEMENT_KEYS.USER_DESCRIPTION),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD),
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"),
];
// 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
const response = await apiClient.post(
"/multilang/batch",
{
langKeys: MENU_MANAGEMENT_LANG_KEYS,
companyCode: "*", // 모든 회사
menuCode: "menu.management", // 메뉴관리 메뉴
userLang: userLang, // body에 포함
},
{
params: {}, // query params는 비움
},
);
const results = await Promise.all(textPromises);
if (response.data.success) {
const translations = response.data.data;
console.log("🌐 배치 다국어 텍스트 응답:", translations);
// 결과를 키와 매핑
const keys = [
MENU_MANAGEMENT_KEYS.TITLE,
MENU_MANAGEMENT_KEYS.DESCRIPTION,
MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE,
MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN,
MENU_MANAGEMENT_KEYS.MENU_TYPE_USER,
MENU_MANAGEMENT_KEYS.ADMIN_MENU,
MENU_MANAGEMENT_KEYS.USER_MENU,
MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION,
MENU_MANAGEMENT_KEYS.USER_DESCRIPTION,
MENU_MANAGEMENT_KEYS.BUTTON_ADD,
MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL,
MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB,
MENU_MANAGEMENT_KEYS.BUTTON_EDIT,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT,
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",
];
// 번역 결과를 상태에 저장
console.log("🔧 setUiTexts 호출 전:", { translationsCount: Object.keys(translations).length });
setUiTexts(translations);
console.log("🔧 setUiTexts 호출 후 - translations:", translations);
keys.forEach((key, index) => {
texts[key] = results[index];
});
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
setTranslationCache(userLang, translations);
} else {
console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
setUiTexts(texts);
// 번역 텍스트를 캐시에 저장
setTranslationCache(currentUserLang, texts);
console.log("🌐 UI 다국어 텍스트 로드 완료:", texts);
// API 실패 시 기본 텍스트 사용
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = key; // 키를 기본값으로 사용
});
setUiTexts(defaultTexts);
}
} catch (error) {
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
// API 실패 시 기본 텍스트 사용
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = key; // 키를 기본값으로 사용
});
setUiTexts(defaultTexts);
} finally {
setUiTextsLoading(false);
}
};
// UI 텍스트 가져오기 함수
const getUIText = async (
key: string,
params?: Record<string, string | number>,
fallback?: string,
): Promise<string> => {
// uiTexts에서 먼저 찾기
let text = uiTexts[key];
// UI 텍스트 가져오기 함수 (동기 버전만 사용)
// getUIText 함수는 제거 - getUITextSync만 사용
// uiTexts에 없으면 비동기적으로 API 호출
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;
};
// 동기 버전 (기존 호환성을 위해)
// 동기 버전 (DB에서 가져온 번역 텍스트 사용)
const getUITextSync = (key: string, params?: Record<string, string | number>, fallback?: string): string => {
// uiTexts에서 번역 텍스트 찾기
let text = uiTexts[key];
// 디버깅: uiTexts 상태 확인
if (!text) {
console.log(`🔍 getUITextSync - 키 "${key}"를 uiTexts에서 찾을 수 없음`);
console.log("🔍 uiTexts 상태:", {
count: Object.keys(uiTexts).length,
sampleKeys: Object.keys(uiTexts).slice(0, 5),
});
text = fallback || key;
} else {
console.log(`✅ getUITextSync - 키 "${key}" 번역 텍스트 찾음: "${text}"`);
}
// 파라미터 치환
@ -401,11 +389,11 @@ export const MenuManagement: React.FC = () => {
return text || key;
};
// 다국어 API 테스트 함수
// 다국어 API 테스트 함수 (getUITextSync 사용)
const testMultiLangAPI = async () => {
console.log("🧪 다국어 API 테스트 시작");
try {
const text = await getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_MENU);
const text = getUITextSync("menu.management.admin");
console.log("🧪 다국어 API 테스트 결과:", text);
} catch (error) {
console.error("❌ 다국어 API 테스트 실패:", error);
@ -513,11 +501,11 @@ export const MenuManagement: React.FC = () => {
const handleDeleteSelectedMenus = async () => {
if (selectedMenus.size === 0) {
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_SELECT_MENU_DELETE));
toast.error(getUITextSync("message.validation.select.menu.delete"));
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;
}
@ -526,7 +514,7 @@ export const MenuManagement: React.FC = () => {
const menuIds = Array.from(selectedMenus);
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);
console.log("삭제 API 응답:", response);
@ -552,12 +540,10 @@ export const MenuManagement: React.FC = () => {
// 삭제 결과 메시지
if (failedCount === 0) {
toast.success(
getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_SUCCESS, { count: deletedCount }),
);
toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
} else {
toast.success(
getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_PARTIAL, {
getUITextSync("message.menu.delete.batch.partial", {
success: deletedCount,
failed: failedCount,
}),
@ -569,7 +555,7 @@ export const MenuManagement: React.FC = () => {
}
} catch (error) {
console.error("메뉴 삭제 중 오류:", error);
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_FAILED));
toast.error(getUITextSync("message.menu.delete.failed"));
} finally {
setDeleting(false);
}
@ -605,7 +591,7 @@ export const MenuManagement: React.FC = () => {
}
} catch (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 +644,29 @@ export const MenuManagement: React.FC = () => {
};
const getMenuTypeString = () => {
return selectedMenuType === "admin"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN)
: getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_USER);
return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
};
const getMenuTypeValue = () => {
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) {
return (
<div className="flex h-64 items-center justify-center">
@ -676,14 +676,14 @@ export const MenuManagement: React.FC = () => {
}
return (
<LoadingOverlay isLoading={deleting} text="메뉴 삭제 중...">
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
<div className="flex h-full flex-col">
{/* 메인 컨텐츠 - 2:8 비율 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r bg-gray-50">
<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">
<Card
className={`cursor-pointer transition-all ${
@ -694,9 +694,9 @@ export const MenuManagement: React.FC = () => {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<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">
{getUITextSync(MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION)}
{getUITextSync("menu.management.admin.description")}
</p>
</div>
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
@ -715,9 +715,9 @@ export const MenuManagement: React.FC = () => {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<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">
{getUITextSync(MENU_MANAGEMENT_KEYS.USER_DESCRIPTION)}
{getUITextSync("menu.management.user.description")}
</p>
</div>
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>{userMenus.length}</Badge>
@ -733,7 +733,7 @@ export const MenuManagement: React.FC = () => {
<div className="flex h-full flex-col p-6">
<div className="mb-6 flex-shrink-0">
<h2 className="mb-2 text-xl font-semibold">
{getMenuTypeString()} {getMenuTextSync(MENU_MANAGEMENT_KEYS.LIST_TITLE)}
{getMenuTypeString()} {getUITextSync("menu.list.title")}
</h2>
</div>
@ -741,7 +741,7 @@ export const MenuManagement: React.FC = () => {
<div className="mb-4 flex-shrink-0">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<Label htmlFor="company">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY)}</Label>
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
<div className="company-dropdown relative">
<button
type="button"
@ -750,11 +750,11 @@ export const MenuManagement: React.FC = () => {
>
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)
? getUITextSync("filter.company.all")
: selectedCompany === "*"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
? getUITextSync("filter.company.common")
: companies.find((c) => c.code === selectedCompany)?.name ||
getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)}
getUITextSync("filter.company.all")}
</span>
<svg
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
@ -771,7 +771,7 @@ export const MenuManagement: React.FC = () => {
{/* 검색 입력 */}
<div className="border-b p-2">
<Input
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_SEARCH)}
placeholder={getUITextSync("filter.company.search")}
value={companySearchText}
onChange={(e) => setCompanySearchText(e.target.value)}
className="h-8 text-sm"
@ -789,7 +789,7 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText("");
}}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)}
{getUITextSync("filter.company.all")}
</div>
<div
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
@ -799,7 +799,7 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText("");
}}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)}
{getUITextSync("filter.company.common")}
</div>
{companies
@ -819,7 +819,7 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText("");
}}
>
{company.code === "*" ? "공통" : company.name}
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
</div>
))}
</div>
@ -829,9 +829,9 @@ export const MenuManagement: React.FC = () => {
</div>
<div>
<Label htmlFor="search">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_SEARCH)}</Label>
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
<Input
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_SEARCH_PLACEHOLDER)}
placeholder={getUITextSync("filter.search.placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
@ -847,13 +847,13 @@ export const MenuManagement: React.FC = () => {
variant="outline"
className="w-full"
>
{getUITextSync(MENU_MANAGEMENT_KEYS.FILTER_RESET)}
{getUITextSync("filter.reset")}
</Button>
</div>
<div className="flex items-end">
<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>
@ -862,11 +862,11 @@ export const MenuManagement: React.FC = () => {
<div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<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 className="flex space-x-2">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
{getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL)}
{getUITextSync("button.add.top.level")}
</Button>
{selectedMenus.size > 0 && (
<Button
@ -878,10 +878,10 @@ export const MenuManagement: React.FC = () => {
{deleting ? (
<>
<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,
})
)}

View File

@ -110,6 +110,34 @@ export const useAuth = () => {
if (response.success && 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;
}
@ -348,6 +376,12 @@ export const useAuth = () => {
// JWT 토큰 제거
TokenManager.removeToken();
// 로케일 정보도 제거
localStorage.removeItem("userLocale");
localStorage.removeItem("userLocaleLoaded");
(window as any).__GLOBAL_USER_LANG = undefined;
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
setUser(null);
setAuthStatus({
@ -365,6 +399,13 @@ export const useAuth = () => {
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
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);
setAuthStatus({
isLoggedIn: false,

View File

@ -6,70 +6,82 @@ let globalUserLang = "KR";
let globalChangeLangCallback: ((lang: string) => void) | null = null;
export const useMultiLang = (options: { companyCode?: string } = {}) => {
const [userLang, setUserLang] = useState<string>("KR");
const [userLang, setUserLang] = useState<string | null>(null); // null로 시작
const companyCode = options.companyCode || "*";
// 전역 언어 상태 동기화
// 전역 언어 상태 동기화 (무한 루프 방지)
useEffect(() => {
if (globalUserLang !== userLang) {
// 초기 로딩 시에만 동기화
if (globalUserLang && globalUserLang !== userLang) {
setUserLang(globalUserLang);
}
}, [globalUserLang]);
}, []); // 의존성 배열을 비워서 한 번만 실행
// 언어 변경 시 전역 콜백 호출
// 언어 변경 시 전역 콜백 호출 (무한 루프 방지)
useEffect(() => {
if (globalChangeLangCallback) {
// 언어가 설정된 경우에만 콜백 호출
if (globalChangeLangCallback && userLang) {
globalChangeLangCallback(userLang);
}
}, [userLang]);
// 사용자 로케일 조회 (한 번만 실행)
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);
return;
}
const fetchUserLocale = async () => {
try {
console.log("🔍 사용자 로케일 조회 시작");
const response = await apiClient.get("/admin/user-locale");
// 전역 로케일이 아직 로드되지 않았으면 대기
console.log("⏳ 전역 로케일 로드 대기 중...");
if (response.data.success && response.data.data) {
const userLocale = response.data.data;
console.log("✅ 사용자 로케일 조회 성공:", userLocale);
// 데이터베이스의 locale 값을 그대로 사용 (매핑 없음)
setUserLang(userLocale);
globalUserLang = userLocale; // 전역 상태도 업데이트
return;
}
// 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;
}
// 주기적으로 전역 로케일 확인
const checkInterval = setInterval(() => {
if ((window as any).__GLOBAL_USER_LOCALE_LOADED) {
const globalLocale = (window as any).__GLOBAL_USER_LANG;
console.log("🌐 전역에서 사용자 로케일 확인됨:", globalLocale);
setUserLang(globalLocale);
globalUserLang = globalLocale;
clearInterval(checkInterval);
}
};
}, 100);
fetchUserLocale();
// 5초 후 타임아웃
setTimeout(() => {
clearInterval(checkInterval);
if (!userLang) {
console.warn("⚠️ 전역 로케일 로드 타임아웃, 기본값 사용");
setUserLang("KR");
globalUserLang = "KR";
}
}, 5000);
return () => clearInterval(checkInterval);
}, []);
// 다국어 텍스트 가져오기 (배치 조회 방식)
@ -114,17 +126,27 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
}
};
// 언어 변경
// 언어 변경 (무한 루프 방지)
const changeLang = async (newLang: string) => {
// 같은 언어로 변경하려는 경우 무시
if (newLang === userLang) {
console.log("🔄 같은 언어로 변경 시도 무시:", newLang);
return;
}
try {
console.log("🔄 언어 변경 시작:", { from: userLang, to: newLang });
// 백엔드에 사용자 로케일 설정 요청
const response = await apiClient.post("/admin/user-locale", {
locale: newLang,
});
if (response.data.success) {
setUserLang(newLang);
// 전역 상태 먼저 업데이트
globalUserLang = newLang;
// 로컬 상태 업데이트
setUserLang(newLang);
console.log("✅ 사용자 로케일 변경 성공:", newLang);
} else {
console.error("❌ 사용자 로케일 변경 실패:", response.data.message);
@ -132,8 +154,8 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
} catch (error) {
console.error("❌ 사용자 로케일 변경 중 오류:", error);
// 오류 시에도 로컬 상태는 변경
setUserLang(newLang);
globalUserLang = newLang;
setUserLang(newLang);
}
};

View File

@ -59,10 +59,10 @@ apiClient.interceptors.request.use(
console.warn("⚠️ 토큰이 없습니다.");
}
// 언어 정보를 쿼리 파라미터에 추가
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
if (config.method?.toUpperCase() === "GET") {
// 전역 언어 상태에서 현재 언어 가져오기
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "ko" : "ko";
// 전역 언어 상태에서 현재 언어 가져오기 (DB 값 그대로 사용)
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "KR" : "KR";
console.log("🌐 API 요청 시 언어 정보:", currentLang);
if (config.params) {

View File

@ -145,11 +145,11 @@ export const menuApi = {
menuCode?: string;
keyType?: string;
}): Promise<ApiResponse<LangKey[]>> => {
console.log("🔍 다국어 키 목록 조회 API 호출:", "/admin/multilang/keys", params);
console.log("🔍 다국어 키 목록 조회 API 호출:", "/multilang/keys", params);
try {
// Node.js 백엔드의 실제 라우팅과 일치하도록 수정
const response = await apiClient.get("/admin/multilang/keys", { params });
const response = await apiClient.get("/multilang/keys", { params });
console.log("✅ 다국어 키 목록 조회 성공:", response.data);
return response.data;
} catch (error) {

View File

@ -3,8 +3,8 @@ import { apiClient } from "../api/client";
// 메뉴 관리 화면 다국어 키 상수
export const MENU_MANAGEMENT_KEYS = {
// 기본 정보
TITLE: "title",
DESCRIPTION: "description",
TITLE: "menu.management.title",
DESCRIPTION: "menu.management.description",
MENU_TYPE_TITLE: "menu.type.title",
MENU_TYPE_ADMIN: "menu.type.admin",
MENU_TYPE_USER: "menu.type.user",