ERP-node/frontend/app/(main)/admin/layout.tsx

625 lines
23 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Settings, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { menuApi, MenuItem } from "@/lib/api/menu";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { useRouter, usePathname } from "next/navigation";
import {
getMenuTextSync,
MENU_MANAGEMENT_KEYS,
useMenuManagementText,
setTranslationCache,
} from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang";
import { tokenSync } from "@/lib/sessionManager";
// 아이콘 매핑
const ICON_MAP: { [key: string]: any } = {
Settings,
};
// 기본 관리자 메뉴 (메뉴관리에서 등록된 메뉴가 없을 때 사용)
// 동적으로 생성되는 메뉴들만 사용하므로 기본 메뉴 제거
const DEFAULT_ADMIN_MENUS: any[] = [];
/**
* 관리자 팝업 전용 레이아웃
*/
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { userLang, setGlobalChangeLangCallback } = useMultiLang({ companyCode: "*" });
const { getMenuText } = useMenuManagementText();
const [adminMenus, setAdminMenus] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [activeMenu, setActiveMenu] = useState("company");
const [translationsLoaded, setTranslationsLoaded] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(true); // 사이드바 토글 상태 추가
const [isAuthorized, setIsAuthorized] = useState<boolean | null>(null);
const [menuTranslations, setMenuTranslations] = useState<{
title: string;
description: string;
}>({
title: "메뉴 관리",
description: "시스템의 메뉴 구조와 권한을 관리합니다.",
});
// 토큰 확인 및 인증 상태 체크
useEffect(() => {
console.log("=== AdminLayout 토큰 확인 ===");
console.log("현재 경로:", pathname);
console.log("현재 URL:", window.location.href);
const checkToken = () => {
const token = localStorage.getItem("authToken");
console.log("localStorage 토큰:", token ? "존재" : "없음");
console.log("토큰 길이:", token ? token.length : 0);
console.log("토큰 시작:", token ? token.substring(0, 30) + "..." : "없음");
// sessionStorage도 확인
const sessionToken = sessionStorage.getItem("authToken");
console.log("sessionStorage 토큰:", sessionToken ? "존재" : "없음");
// 현재 인증 상태도 확인
console.log("현재 isAuthorized 상태:", isAuthorized);
// 토큰이 없으면 sessionStorage에서 복원 시도
if (!token && sessionToken) {
console.log("🔄 sessionStorage에서 토큰 복원 시도");
const restored = tokenSync.restoreFromSession();
if (restored) {
console.log("✅ 토큰 복원 성공");
setIsAuthorized(true);
return;
}
}
// 토큰 유효성 검증
if (token && !tokenSync.validateToken(token)) {
console.log("❌ 토큰 유효성 검증 실패");
localStorage.removeItem("authToken");
sessionStorage.removeItem("authToken");
setIsAuthorized(false);
setTimeout(() => {
console.log("리다이렉트 실행: /login");
window.location.href = "/login";
}, 5000);
return;
}
if (!token) {
console.log("❌ 토큰이 없음 - 로그인 페이지로 이동");
setIsAuthorized(false);
// 5초 후 리다이렉트 (디버깅을 위해 시간 늘림)
setTimeout(() => {
console.log("리다이렉트 실행: /login");
window.location.href = "/login";
}, 5000);
return;
}
// 토큰이 있으면 인증된 것으로 간주
console.log("✅ 토큰 존재 - 인증된 것으로 간주");
setIsAuthorized(true);
// 토큰 강제 동기화 (다른 탭과 동기화)
tokenSync.forceSync();
};
// 초기 토큰 확인
checkToken();
// localStorage 변경 이벤트 리스너 추가
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "authToken") {
console.log("🔄 localStorage authToken 변경 감지:", e.newValue ? "설정됨" : "제거됨");
checkToken();
}
};
// 페이지 포커스 시 토큰 재확인
const handleFocus = () => {
console.log("🔄 페이지 포커스 - 토큰 재확인");
checkToken();
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("focus", handleFocus);
};
}, [pathname]);
// 관리자 메뉴 로드
useEffect(() => {
if (isAuthorized) {
loadAdminMenus();
}
}, [isAuthorized]);
// pathname 변경 시 활성 메뉴 업데이트
useEffect(() => {
if (adminMenus.length > 0) {
const currentPath = pathname;
console.log("🔄 경로 변경 감지:", currentPath);
// 정확한 URL 매칭을 위한 함수
const isExactMatch = (menuUrl: string, currentPath: string) => {
// 메뉴 URL이 현재 경로와 정확히 일치하거나
// 메뉴 URL이 현재 경로의 시작 부분과 일치하는지 확인
// 단, 메뉴 관리 페이지는 제외 (별도 처리)
if (currentPath === "/admin/menu" || currentPath.startsWith("/admin/menu/")) {
return false; // 메뉴 관리 페이지는 별도 로직에서 처리
}
// 정확한 매칭: URL이 정확히 일치하거나 현재 경로가 메뉴 URL로 시작하는 경우
// 단, 메뉴 URL이 "/admin"이고 현재 경로가 "/admin/menu"인 경우는 제외
if (menuUrl === "/admin" && currentPath.startsWith("/admin/menu")) {
return false;
}
// 정확한 매칭: URL이 정확히 일치하는 경우
if (menuUrl === currentPath) {
return true;
}
// 경로 매칭: 현재 경로가 메뉴 URL로 시작하는 경우
// 단, 메뉴 URL이 슬래시로 끝나지 않으면 정확한 경로 매칭만 허용
if (currentPath.startsWith(menuUrl + "/")) {
return true;
}
return false;
};
// 메뉴 관리 페이지인지 확인 (정확한 매칭)
if (currentPath === "/admin/menu" || currentPath.startsWith("/admin/menu/")) {
// 메뉴 관리 페이지는 URL 매칭과 메뉴명 매칭 모두 시도
const menuManagementMenu = adminMenus.find((menu) => {
console.log("🔍 메뉴 검사:", {
id: menu.id,
name: menu.name,
url: menu.url,
level: menu.lev,
});
// URL 매칭 (가장 우선)
if (menu.url && (menu.url === "/admin/menu" || menu.url === "/admin/menu/")) {
console.log("✅ URL 매칭 성공:", menu.url);
return true;
}
// 메뉴명 매칭 (메뉴 관리 관련 메뉴)
if (menu.name && (menu.name.includes("메뉴 관리") || menu.name.includes("Menu Management"))) {
console.log("✅ 메뉴명 매칭 성공:", menu.name);
return true;
}
return false;
});
if (menuManagementMenu && menuManagementMenu.id) {
console.log("✅ 메뉴 관리 메뉴 활성화:", menuManagementMenu.id, "이름:", menuManagementMenu.name);
setActiveMenu(menuManagementMenu.id);
} else {
console.log("❌ 메뉴 관리 메뉴를 찾을 수 없음");
}
} else {
// 다른 메뉴들에 대한 정확한 매칭
console.log("🔍 다른 메뉴 매칭 시도:", currentPath);
// 모든 메뉴에 대해 매칭 시도
const matchingMenus = adminMenus.filter((menu) => menu.url && isExactMatch(menu.url, currentPath));
if (matchingMenus.length > 0) {
// 가장 구체적인 매칭을 찾기 (가장 긴 URL이 우선)
const bestMatch = matchingMenus.reduce((best, current) => {
return current.url && current.url.length > (best.url ? best.url.length : 0) ? current : best;
});
console.log("✅ 매칭된 메뉴 활성화:", bestMatch.id, "URL:", bestMatch.url, "레벨:", bestMatch.lev);
setActiveMenu(bestMatch.id);
} else {
// URL 매칭이 실패한 경우, 경로 기반으로 가장 적절한 메뉴 찾기
console.log("🔍 URL 매칭 실패, 경로 기반 매칭 시도");
// 현재 경로의 세그먼트 수 계산
const pathSegments = currentPath.split("/").filter((segment: string) => segment.length > 0);
const currentPathLevel = pathSegments.length;
console.log("현재 경로 레벨:", currentPathLevel, "세그먼트:", pathSegments);
// 현재 경로 레벨과 일치하는 메뉴 찾기
const levelMatchingMenus = adminMenus.filter((menu) => {
if (!menu.url) return false;
const menuPathSegments = menu.url.split("/").filter((segment: string) => segment.length > 0);
const menuLevel = menuPathSegments.length;
// 현재 경로가 메뉴 URL로 시작하고, 레벨이 일치하는 경우
return currentPath.startsWith(menu.url) && menuLevel === currentPathLevel;
});
if (levelMatchingMenus.length > 0) {
const bestLevelMatch = levelMatchingMenus.reduce((best, current) => {
return current.url && current.url.length > (best.url ? best.url.length : 0) ? current : best;
});
console.log(
"✅ 레벨 기반 매칭 성공:",
bestLevelMatch.id,
"URL:",
bestLevelMatch.url,
"레벨:",
bestLevelMatch.lev,
);
setActiveMenu(bestLevelMatch.id);
} else {
console.log("❌ 매칭되는 메뉴를 찾을 수 없음:", currentPath);
// 매칭되는 메뉴가 없으면 첫 번째 메뉴를 기본값으로 설정
if (adminMenus.length > 0) {
console.log("🔧 기본 메뉴로 설정:", adminMenus[0].id);
setActiveMenu(adminMenus[0].id);
}
}
}
}
}
}, [pathname, adminMenus]);
// 전역 언어 변경 콜백 등록
useEffect(() => {
setGlobalChangeLangCallback((newLang: string) => {
console.log("🔄 전역 언어 변경 감지:", newLang);
// 번역 상태 리셋
setMenuTranslations({
title: "메뉴 관리",
description: "시스템의 메뉴 구조와 권한을 관리합니다.",
});
// 새로운 번역 로드 및 메뉴 데이터 재로드
setTimeout(() => {
loadTranslations();
// 언어 변경 시 메뉴 데이터를 다시 로드하여 번역된 메뉴명 가져오기
loadAdminMenus();
}, 100);
});
return () => {
setGlobalChangeLangCallback(() => {});
};
}, [adminMenus]);
// 번역 데이터 로드
useEffect(() => {
if (userLang) {
console.log("🔄 userLang 변경 감지:", userLang);
// 번역 상태 리셋
setTranslationsLoaded(false);
setMenuTranslations({
title: "메뉴 관리",
description: "시스템의 메뉴 구조와 권한을 관리합니다.",
});
// 새로운 번역 로드
loadTranslations();
}
}, [userLang]);
// 컴포넌트 마운트 시 userLang이 설정될 때까지 대기
useEffect(() => {
if (userLang) {
console.log("🔄 userLang 설정됨, 번역 로드 시작:", userLang);
loadTranslations();
}
}, [userLang]); // userLang이 설정될 때마다 실행
// 키보드 단축키로 사이드바 토글
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl+B 또는 Cmd+B로 사이드바 토글
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault();
setSidebarOpen((prev) => !prev);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// 번역 로드 완료 이벤트 감지
useEffect(() => {
const handleTranslationLoaded = (event: any) => {
const { key, text } = event.detail;
if (key === MENU_MANAGEMENT_KEYS.TITLE) {
setMenuTranslations((prev) => ({ ...prev, title: text }));
} else if (key === MENU_MANAGEMENT_KEYS.DESCRIPTION) {
setMenuTranslations((prev) => ({ ...prev, description: text }));
}
setForceUpdate((prev) => prev + 1);
};
window.addEventListener("translation-loaded", handleTranslationLoaded);
return () => window.removeEventListener("translation-loaded", handleTranslationLoaded);
}, []);
const loadTranslations = async () => {
try {
// userLang이 설정되지 않았으면 번역 로드하지 않음
if (!userLang) {
console.log("⏳ userLang이 설정되지 않음, 번역 로드 대기");
return;
}
console.log("🌐 Admin Layout 번역 로드 시작", {
userLang,
});
// API 직접 호출로 현재 언어 사용 (배치 조회 방식)
const companyCode = "*";
try {
// 배치 조회 API 사용
const response = await apiClient.post(
"/multilang/batch",
{
langKeys: [MENU_MANAGEMENT_KEYS.TITLE, MENU_MANAGEMENT_KEYS.DESCRIPTION],
},
{
params: {
companyCode,
menuCode: "MENU_MANAGEMENT",
userLang: userLang,
},
},
);
if (response.data.success && response.data.data) {
const translations = response.data.data;
const title = translations[MENU_MANAGEMENT_KEYS.TITLE] || "메뉴 관리";
const description =
translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다.";
// 번역 캐시에 저장
setTranslationCache(userLang, translations);
// 상태 업데이트
setMenuTranslations({ title, description });
console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang });
} else {
// 전역 사용자 로케일 확인하여 기본값 설정
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: globalUserLang });
}
} catch (error) {
console.error("❌ Admin Layout 배치 번역 로드 실패:", error);
// 오류 시에도 전역 사용자 로케일 확인하여 기본값 설정
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) {
console.error("❌ Admin Layout 번역 로드 실패:", error);
}
};
const loadAdminMenus = async () => {
try {
setLoading(true);
const response = await menuApi.getAdminMenus();
if (response.success && response.data) {
// 메뉴 데이터를 관리자 메뉴 형식으로 변환
const convertedMenus = response.data.map((menu: MenuItem) => ({
id: menu.objid || menu.OBJID,
name:
menu.translated_name || menu.TRANSLATED_NAME || menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음",
icon: Settings, // 기본 아이콘
description: menu.translated_desc || menu.TRANSLATED_DESC || menu.menu_desc || menu.MENU_DESC || "메뉴 설명",
url: menu.menu_url || menu.MENU_URL,
menuType: menu.menu_type || menu.MENU_TYPE,
// 번역 키 정보 저장 (언어 변경 시 사용)
lang_key: menu.lang_key || menu.LANG_KEY,
lang_key_desc: menu.lang_key_desc || menu.LANG_KEY_DESC,
// 레벨 정보 추가
lev: (menu as any).lev || (menu as any).LEV,
}));
// 동적으로 생성된 메뉴들만 사용
console.log("📋 로드된 메뉴 목록:");
convertedMenus.forEach((menu, index) => {
console.log(` ${index + 1}. ID: ${menu.id}, 이름: ${menu.name}, URL: ${menu.url}, 레벨: ${menu.lev}`);
});
setAdminMenus(convertedMenus);
// 백엔드에서 이미 번역된 데이터를 제공하므로 별도 번역 로드 불필요
// await loadDynamicMenuTranslations(convertedMenus);
}
} catch (error) {
console.error("관리자 메뉴 로드 오류:", error);
} finally {
setLoading(false);
}
};
const currentMenu = adminMenus.find((menu) => menu.id === activeMenu) || adminMenus[0];
// 메뉴 클릭 시 URL 이동 처리
const handleMenuClick = (menu: any) => {
console.log("=== 메뉴 클릭 ===");
console.log("클릭된 메뉴:", menu);
// 메뉴 클릭 시 토큰 재확인
const token = localStorage.getItem("authToken");
console.log("메뉴 클릭 시 토큰 확인:", token ? "존재" : "없음");
if (!token) {
console.log("❌ 메뉴 클릭 시 토큰이 없음 - 경고 표시");
alert("인증 토큰이 없습니다. 다시 로그인해주세요.");
window.location.href = "/login";
return;
}
setActiveMenu(menu.id);
if (menu.url) {
// 외부 URL인 경우 새 탭에서 열기
if (menu.url.startsWith("http://") || menu.url.startsWith("https://")) {
console.log("외부 URL 열기:", menu.url);
window.open(menu.url, "_blank");
} else {
// 내부 URL인 경우 라우터로 이동
console.log("내부 URL 이동:", menu.url);
router.push(menu.url);
}
} else {
// URL이 없는 메뉴는 현재 페이지에서 컨텐츠만 변경
console.log("URL이 없는 메뉴:", menu);
}
};
return (
<div className="bg-background flex h-screen">
{/* 인증 상태 확인 */}
{isAuthorized === null && (
<div className="flex h-screen w-full items-center justify-center">
<div className="text-center">
<h1 className="mb-4 text-2xl font-bold"> ...</h1>
<LoadingSpinner />
</div>
</div>
)}
{isAuthorized === false && (
<div className="flex h-screen w-full items-center justify-center">
<div className="text-center">
<h1 className="mb-4 text-2xl font-bold text-red-600"> </h1>
<p className="mb-4"> . 3 .</p>
</div>
</div>
)}
{isAuthorized === true && (
<>
{/* 왼쪽 사이드바 */}
<div className={cn("bg-muted/30 border-r p-6 transition-all duration-300", sidebarOpen ? "w-64" : "w-16")}>
<div className="mb-4 flex items-center justify-between">
{sidebarOpen && (
<>
<div>
<h2 className="text-lg font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarOpen(false)}
title="사이드바 접기 (Ctrl+B)"
>
<X className="h-4 w-4" />
</Button>
</>
)}
{!sidebarOpen && (
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarOpen(true)}
title="사이드바 펼치기 (Ctrl+B)"
className="w-full justify-center"
>
<Menu className="h-4 w-4" />
</Button>
)}
</div>
<nav className="space-y-2">
{loading ? (
<div className="flex items-center justify-center p-4">
<LoadingSpinner />
</div>
) : (
adminMenus.map((menu: any) => {
const IconComponent = menu.icon;
const isActive = activeMenu === menu.id;
return (
<Button
key={menu.id}
variant={isActive ? "default" : "ghost"}
className={cn(
"h-10 w-full justify-start gap-2 transition-all duration-200",
isActive && "bg-primary text-primary-foreground",
!sidebarOpen && "justify-center px-2",
)}
onClick={() => handleMenuClick(menu)}
title={!sidebarOpen ? menu.name : undefined}
>
<IconComponent className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span className="truncate">{menu.name}</span>}
</Button>
);
})
)}
</nav>
</div>
{/* 오른쪽 컨텐츠 영역 */}
<div className="flex-1 overflow-auto p-6">
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">
{pathname === "/admin/menu" || pathname.startsWith("/admin/menu/")
? menuTranslations.title
: currentMenu?.name || "관리자 설정"}
</h1>
<p className="text-muted-foreground">
{pathname === "/admin/menu" || pathname.startsWith("/admin/menu/")
? menuTranslations.description
: currentMenu?.description || "시스템 관리 도구"}
</p>
</div>
{children}
</div>
</div>
</>
)}
</div>
);
}