admin 렌더링 방식 변경

This commit is contained in:
hyeonsu 2025-08-26 17:20:45 +09:00
parent c3549935c1
commit 014688974e
11 changed files with 533 additions and 1295 deletions

View File

@ -0,0 +1,3 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@ -1,624 +0,0 @@
"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>
);
}

View File

@ -1,90 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { RefreshCw, Users, Shield, Settings, BarChart3 } from "lucide-react";
/**
* ()
*
*
*/
export default function AdminPage() {
const [tokenInfo, setTokenInfo] = useState<any>({});
const [isAuthorized, setIsAuthorized] = useState<boolean | null>(null);
useEffect(() => {
console.log("=== AdminPage 시작 ===");
const token = localStorage.getItem("authToken");
console.log("localStorage 토큰:", token ? "존재" : "없음");
const info = {
hasToken: !!token,
tokenLength: token ? token.length : 0,
tokenStart: token ? token.substring(0, 30) + "..." : "없음",
currentUrl: window.location.href,
timestamp: new Date().toISOString(),
};
setTokenInfo(info);
console.log("토큰 정보:", info);
if (!token) {
console.log("토큰이 없음 - 로그인 페이지로 이동");
setIsAuthorized(false);
// 3초 후 리다이렉트
setTimeout(() => {
window.location.href = "/login";
}, 3000);
return;
}
// 토큰이 있으면 인증된 것으로 간주
console.log("토큰 존재 - 인증된 것으로 간주");
setIsAuthorized(true);
}, []);
if (isAuthorized === null) {
return (
<div className="p-6">
<h1 className="mb-4 text-2xl font-bold"> ...</h1>
return (
<div className="space-y-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="text-gray-600"> </p>
</div>
<Button variant="outline" className="gap-2">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
);
}
if (isAuthorized === false) {
return (
<div className="p-6">
<h1 className="mb-4 text-2xl font-bold text-red-600"> </h1>
<p> . 3 .</p>
{/* 관리자 기능 카드들 */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-50">
<Users className="h-6 w-6 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
<div className="mt-4 rounded bg-yellow-100 p-4">
<h2 className="mb-2 font-semibold"> </h2>
<pre className="text-xs">{JSON.stringify(tokenInfo, null, 2)}</pre>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
<Shield className="h-6 w-6 text-green-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-50">
<Settings className="h-6 w-6 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-orange-50">
<BarChart3 className="h-6 w-6 text-orange-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="p-6">
<h1 className="mb-4 text-2xl font-bold"> </h1>
<p className="mb-4 text-green-600"> ! .</p>
<div className="mb-4 rounded bg-green-100 p-4">
<h2 className="mb-2 font-semibold"> </h2>
<pre className="text-xs">{JSON.stringify(tokenInfo, null, 2)}</pre>
</div>
<div className="rounded bg-blue-100 p-4">
<h2 className="mb-2 font-semibold"> </h2>
<p> .</p>
<button
onClick={() => {
alert("관리자 기능이 정상 작동합니다!");
}}
className="mt-2 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
</button>
{/* 최근 활동 */}
<div className="rounded-lg border bg-white p-6 shadow-sm">
<h3 className="mb-4 text-lg font-semibold"> </h3>
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-gray-100 py-2 last:border-0">
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-600"> .</p>
</div>
<span className="text-sm text-gray-500">2 </span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 py-2 last:border-0">
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-600"> .</p>
</div>
<span className="text-sm text-gray-500">15 </span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 py-2 last:border-0">
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-600"> .</p>
</div>
<span className="text-sm text-gray-500">1 </span>
</div>
</div>
</div>
</div>
);

View File

@ -1,10 +1,13 @@
import { AuthProvider } from "@/contexts/AuthContext";
import { MenuProvider } from "@/contexts/MenuContext";
import { AppLayout } from "@/components/layout/AppLayout";
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<MenuProvider>{children}</MenuProvider>
<MenuProvider>
<AppLayout>{children}</AppLayout>
</MenuProvider>
</AuthProvider>
);
}

View File

@ -1,160 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useMenu } from "@/hooks/useMenu";
import { useProfile } from "@/hooks/useProfile";
import { MainHeader } from "@/components/layout/MainHeader";
import { MainSidebar } from "@/components/layout/MainSidebar";
import { ProfileModal } from "@/components/layout/ProfileModal";
import { LAYOUT_CONFIG } from "@/constants/layout";
/**
*
* , UI
*/
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { user, logout, refreshUserData, loading: authLoading } = useAuth();
const [isSidebarOpen, setIsSidebarOpen] = useState(false); // 사이드바 상태
const [isMobile, setIsMobile] = useState(false); // 모바일 여부
// 화면 크기 감지
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768; // md breakpoint
setIsMobile(mobile);
// 모바일에서는 사이드바를 기본적으로 닫아둠
if (mobile) {
setIsSidebarOpen(false);
}
// PC에서는 사이드바 상태를 건드리지 않음 (항상 표시)
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// 메뉴 관련 로직
const { menuList, expandedMenus, handleMenuClick } = useMenu(user, authLoading);
// 프로필 관련 로직
const {
isModalOpen,
formData,
selectedImage,
isSaving,
alertModal,
closeAlert,
openProfileModal,
closeProfileModal,
updateFormData,
selectImage,
removeImage,
saveProfile,
} = useProfile(user, refreshUserData);
/**
* ( )
*/
const handleSidebarToggle = () => {
if (isMobile) {
// 모바일에서만 토글 동작
setIsSidebarOpen(!isSidebarOpen);
}
// PC에서는 아무것도 하지 않음 (항상 표시)
};
/**
*
*/
const handleLogout = async () => {
await logout();
};
return (
<div className="bg-background flex h-screen flex-col">
<MainHeader
user={user}
onSidebarToggle={handleSidebarToggle}
onProfileClick={openProfileModal}
onLogout={handleLogout}
/>
<div className="relative flex-1">
{/* 데스크톱 고정 사이드바 - md 이상에서 항상 표시 */}
<aside
className={`fixed top-14 left-0 z-40 hidden md:block ${LAYOUT_CONFIG.SIDEBAR.WIDTH} bg-background h-[calc(100vh-3.5rem)] border-r`}
>
<MainSidebar
menuList={menuList}
expandedMenus={expandedMenus}
onMenuClick={handleMenuClick}
className="h-full p-4"
/>
</aside>
{/* 모바일 오버레이 사이드바 - md 미만에서만 표시 */}
{isMobile && (
<div
className={`fixed top-14 right-0 bottom-0 left-0 z-50 flex transition-all duration-300 ease-in-out md:hidden ${
isSidebarOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
{/* 배경 오버레이 */}
<div
className={`fixed top-14 right-0 bottom-0 left-0 bg-black/50 transition-opacity duration-300 ${
isSidebarOpen ? "opacity-100" : "opacity-0"
}`}
onClick={() => setIsSidebarOpen(false)}
/>
{/* 사이드바 */}
<aside
className={`relative ${LAYOUT_CONFIG.SIDEBAR.WIDTH} bg-background flex h-full transform flex-col shadow-lg transition-transform duration-300 ease-in-out ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
{/* 메뉴 리스트 */}
<div className="flex-1 overflow-y-auto">
<MainSidebar
menuList={menuList}
expandedMenus={expandedMenus}
onMenuClick={(menu) => {
handleMenuClick(menu);
// 모바일에서 메뉴 클릭 시 사이드바 닫기
if (isMobile && (!menu.children || menu.children.length === 0)) {
setIsSidebarOpen(false);
}
}}
className="p-4"
/>
</div>
</aside>
</div>
)}
{/* Main Content - 반응형 여백 적용 */}
<main className="ml-0 w-full transition-all duration-300 md:ml-64 md:w-auto">
<div className="h-full p-6">{children}</div>
</main>
</div>
{/* 프로필 수정 모달 */}
<ProfileModal
isOpen={isModalOpen}
user={user}
formData={formData}
selectedImage={selectedImage}
isSaving={isSaving}
alertModal={alertModal}
onClose={closeProfileModal}
onFormChange={updateFormData}
onImageSelect={selectImage}
onImageRemove={removeImage}
onSave={saveProfile}
onAlertClose={closeAlert}
/>
</div>
);
}

View File

@ -1,21 +1,39 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
export default function MainHomePage() {
const router = useRouter();
useEffect(() => {
// (main) 그룹의 루트 접속 시 대시보드로 리다이렉트
router.push("/dashboard");
}, [router]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
<p className="mt-2 text-sm text-gray-600"> ...</p>
<div className="space-y-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600">PLM </p>
</div>
<Button variant="outline" className="gap-2">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* 대시보드 컨텐츠 */}
<div className="rounded-lg border bg-white p-6 shadow-sm">
<h3 className="mb-4 text-lg font-semibold">PLM !</h3>
<p className="mb-6 text-gray-600"> .</p>
<div className="flex gap-2">
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-700/10 ring-inset">
Spring Boot
</span>
<span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-700/10 ring-inset">
Next.js
</span>
<span className="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-purple-700/10 ring-inset">
Shadcn/ui
</span>
</div>
</div>
</div>
);

View File

@ -0,0 +1,402 @@
"use client";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Shield,
Menu,
Home,
Settings,
BarChart3,
FileText,
Users,
Package,
ChevronDown,
ChevronRight,
UserCheck,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { MainHeader } from "./MainHeader";
import { ProfileModal } from "./ProfileModal";
// useAuth의 UserInfo 타입을 확장
interface ExtendedUserInfo {
userId: string;
userName: string;
userNameEng?: string;
userNameCn?: string;
deptCode?: string;
deptName?: string;
positionCode?: string;
positionName?: string;
email?: string;
tel?: string;
cellPhone?: string;
userType?: string;
userTypeName?: string;
authName?: string;
partnerCd?: string;
isAdmin: boolean;
sabun?: string;
photo?: string | null;
companyCode?: string;
locale?: string;
}
interface AppLayoutProps {
children: React.ReactNode;
}
// 메뉴 아이콘 매핑 함수
const getMenuIcon = (menuName: string) => {
const name = menuName.toLowerCase();
if (name.includes("대시보드") || name.includes("dashboard")) return <Home className="h-4 w-4" />;
if (name.includes("관리자") || name.includes("admin")) return <Shield className="h-4 w-4" />;
if (name.includes("사용자") || name.includes("user")) return <Users className="h-4 w-4" />;
if (name.includes("프로젝트") || name.includes("project")) return <BarChart3 className="h-4 w-4" />;
if (name.includes("제품") || name.includes("product")) return <Package className="h-4 w-4" />;
if (name.includes("설정") || name.includes("setting")) return <Settings className="h-4 w-4" />;
if (name.includes("로그") || name.includes("log")) return <FileText className="h-4 w-4" />;
if (name.includes("메뉴") || name.includes("menu")) return <Menu className="h-4 w-4" />;
return <FileText className="h-4 w-4" />;
};
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
const filteredMenus = menus
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active")
.sort((a, b) => (a.seq || a.SEQ || 0) - (b.seq || b.SEQ || 0));
// 최상위 레벨에서 "사용자", "관리자" 카테고리가 있으면 그 하위 메뉴들을 직접 표시
if (parentId === "0") {
const allMenus: any[] = [];
for (const menu of filteredMenus) {
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
if (menuName.includes("사용자") || menuName.includes("관리자")) {
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID);
allMenus.push(...childMenus);
} else {
// 일반 메뉴는 그대로 추가
allMenus.push(convertSingleMenu(menu, menus, userInfo));
}
}
return allMenus;
}
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo));
};
// 단일 메뉴 변환 함수
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => {
const menuId = menu.objid || menu.OBJID;
// 사용자 locale 기준으로 번역 처리
const getDisplayText = (menu: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
if (menu.translated_name || menu.TRANSLATED_NAME) {
return menu.translated_name || menu.TRANSLATED_NAME;
}
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = {
: "Administrator",
: "User Management",
: "Menu Management",
: "Dashboard",
: "Permission Management",
: "Code Management",
: "Settings",
: "Log Management",
: "Project Management",
: "Product Management",
};
for (const [korean, english] of Object.entries(translations)) {
if (baseName.includes(korean)) {
return baseName.replace(korean, english);
}
}
} else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = {
: "管理者",
: "ユーザー管理",
: "メニュー管理",
: "ダッシュボード",
: "権限管理",
: "コード管理",
: "設定",
: "ログ管理",
: "プロジェクト管理",
: "製品管理",
};
for (const [korean, japanese] of Object.entries(translations)) {
if (baseName.includes(korean)) {
return baseName.replace(korean, japanese);
}
}
} else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = {
: "管理员",
: "用户管理",
: "菜单管理",
: "仪表板",
: "权限管理",
: "代码管理",
: "设置",
: "日志管理",
: "项目管理",
: "产品管理",
};
for (const [korean, chinese] of Object.entries(translations)) {
if (baseName.includes(korean)) {
return baseName.replace(korean, chinese);
}
}
}
return baseName;
};
const children = convertMenuToUI(allMenus, userInfo, menuId);
return {
id: menuId,
name: getDisplayText(menu),
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || ""),
url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined,
hasChildren: children.length > 0,
};
};
export function AppLayout({ children }: AppLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const { user, logout, refreshUserData } = useAuth();
const { userMenus, adminMenus, loading } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
// 프로필 관련 로직
const {
isModalOpen,
formData,
selectedImage,
isSaving,
alertModal,
closeAlert,
openProfileModal,
closeProfileModal,
updateFormData,
selectImage,
removeImage,
saveProfile,
} = useProfile(user, refreshUserData);
// 현재 경로에 따라 어드민 모드인지 판단
const isAdminMode = pathname.startsWith("/admin");
// 현재 모드에 따라 표시할 메뉴 결정
const currentMenus = isAdminMode ? adminMenus : userMenus;
// 메뉴 토글 함수
const toggleMenu = (menuId: string) => {
const newExpanded = new Set(expandedMenus);
if (newExpanded.has(menuId)) {
newExpanded.delete(menuId);
} else {
newExpanded.add(menuId);
}
setExpandedMenus(newExpanded);
};
// 메뉴 클릭 핸들러
const handleMenuClick = (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else if (menu.url && menu.url !== "#") {
router.push(menu.url);
setSidebarOpen(false);
}
};
// 모드 전환 핸들러
const handleModeSwitch = () => {
if (isAdminMode) {
router.push("/main");
} else {
router.push("/admin");
}
};
// 로그아웃 핸들러
const handleLogout = async () => {
try {
await logout();
router.push("/login");
} catch (error) {
console.error("로그아웃 실패:", error);
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
return (
<div key={menu.id}>
<div
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:cursor-pointer ${
isExpanded ? "bg-slate-100 text-slate-900" : "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
} ${level > 0 ? "ml-6" : ""}`}
onClick={() => handleMenuClick(menu)}
>
<div className="flex items-center">
{menu.icon}
<span className="ml-3">{menu.name}</span>
</div>
{menu.hasChildren && (
<div className="ml-auto">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</div>
)}
</div>
{/* 서브메뉴 */}
{menu.hasChildren && isExpanded && (
<div className="mt-1 space-y-1 pl-6">
{menu.children?.map((child: any) => (
<div
key={child.id}
className="flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm text-slate-600 transition-colors hover:cursor-pointer hover:bg-slate-50 hover:text-slate-900"
onClick={() => handleMenuClick(child)}
>
{child.icon}
<span className="ml-3">{child.name}</span>
</div>
))}
</div>
)}
</div>
);
};
// 사용자 정보가 없으면 로딩 표시
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
<p> ...</p>
</div>
</div>
);
}
// UI 변환된 메뉴 데이터
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
return (
<div className="flex h-screen flex-col bg-slate-50">
{/* MainHeader 컴포넌트 사용 */}
<MainHeader
user={user}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
onProfileClick={openProfileModal}
onLogout={handleLogout}
/>
<div className="flex flex-1">
{/* 모바일 사이드바 오버레이 */}
{sidebarOpen && (
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* 왼쪽 사이드바 */}
<aside
className={`${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
} fixed top-14 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-200 bg-white transition-transform duration-300 lg:relative lg:top-0 lg:z-auto lg:h-full lg:translate-x-0 lg:transform-none`}
>
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{(user as ExtendedUserInfo)?.userType === "admin" && (
<div className="border-b border-slate-200 p-3">
<Button
onClick={handleModeSwitch}
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
isAdminMode
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
: "border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100"
}`}
>
{isAdminMode ? (
<>
<UserCheck className="h-4 w-4" />
</>
) : (
<>
<Shield className="h-4 w-4" />
</>
)}
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto py-4">
<nav className="space-y-1 px-3">
{loading ? (
<div className="animate-pulse space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 rounded bg-slate-200"></div>
))}
</div>
) : (
uiMenus.map((menu) => renderMenu(menu))
)}
</nav>
</div>
</aside>
{/* 가운데 컨텐츠 영역 */}
<main className="bg-background flex-1 p-6">{children}</main>
</div>
{/* 프로필 수정 모달 */}
<ProfileModal
isOpen={isModalOpen}
user={user}
formData={formData}
selectedImage={selectedImage}
isSaving={isSaving}
alertModal={alertModal}
onClose={closeProfileModal}
onFormChange={updateFormData}
onImageSelect={selectImage}
onImageRemove={removeImage}
onSave={saveProfile}
onAlertClose={closeAlert}
/>
</div>
);
}

View File

@ -1,6 +1,5 @@
import { Logo } from "./Logo";
import { SideMenu } from "./SideMenu";
import { AdminButton } from "./AdminButton";
import { UserDropdown } from "./UserDropdown";
interface MainHeaderProps {
@ -19,8 +18,8 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
<div className="flex h-full w-full items-center justify-between px-6">
{/* Left side - Side Menu + Logo */}
<div className="flex h-8 items-center gap-2">
{/* 햄버거 메뉴 버튼 - 모바일에서만 표시 */}
<div className="md:hidden">
{/* 햄버거 메뉴 버튼 - 1024px 미만에서 표시 */}
<div className="lg:hidden">
<SideMenu onSidebarToggle={onSidebarToggle} />
</div>
<Logo />
@ -28,7 +27,6 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
{/* Right side - Admin Button + User Menu */}
<div className="flex h-8 items-center gap-2">
<AdminButton user={user} />
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
</div>
</div>

View File

@ -1,407 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Shield,
User,
LogOut,
Menu,
X,
Home,
Settings,
BarChart3,
FileText,
Users,
Package,
Wrench,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { MenuItem } from "@/lib/api/menu";
// useAuth의 UserInfo 타입을 확장
interface ExtendedUserInfo {
userId: string;
userName: string;
userNameEng?: string;
userNameCn?: string;
deptCode?: string;
deptName?: string;
positionCode?: string;
positionName?: string;
email?: string;
tel?: string;
cellPhone?: string;
userType?: string;
userTypeName?: string;
authName?: string;
partnerCd?: string;
isAdmin: boolean;
sabun?: string;
photo?: string | null;
companyCode?: string;
locale?: string;
}
// useAuth의 UserInfo 타입을 사용하므로 별도 정의 불필요
interface MainLayoutProps {
children: React.ReactNode;
}
// 메뉴 아이콘 매핑 함수
const getMenuIcon = (menuName: string) => {
const name = menuName.toLowerCase();
if (name.includes("대시보드") || name.includes("dashboard")) return <Home className="h-4 w-4" />;
if (name.includes("관리자") || name.includes("admin")) return <Shield className="h-4 w-4" />;
if (name.includes("사용자") || name.includes("user")) return <Users className="h-4 w-4" />;
if (name.includes("프로젝트") || name.includes("project")) return <BarChart3 className="h-4 w-4" />;
if (name.includes("제품") || name.includes("product")) return <Package className="h-4 w-4" />;
if (name.includes("설정") || name.includes("setting")) return <Settings className="h-4 w-4" />;
if (name.includes("로그") || name.includes("log")) return <FileText className="h-4 w-4" />;
if (name.includes("메뉴") || name.includes("menu")) return <Menu className="h-4 w-4" />;
return <FileText className="h-4 w-4" />;
};
// 메뉴 데이터를 UI용으로 변환하는 함수
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
return menus
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active")
.sort((a, b) => (a.seq || a.SEQ || 0) - (b.seq || b.SEQ || 0))
.map((menu) => {
const menuId = menu.objid || menu.OBJID;
// 사용자 locale 기준으로 번역 처리
const getDisplayText = (menu: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
if (menu.translated_name || menu.TRANSLATED_NAME) {
return menu.translated_name || menu.TRANSLATED_NAME;
}
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = {
: "Administrator",
: "User Management",
: "Menu Management",
: "Dashboard",
: "Permission Management",
: "Code Management",
: "Settings",
: "Log Management",
: "Project Management",
: "Product Management",
};
for (const [korean, english] of Object.entries(translations)) {
if (baseName.includes(korean)) {
return baseName.replace(korean, english);
}
}
} else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = {
: "管理者",
: "ユーザー管理",
: "メニュー管理",
: "ダッシュボード",
: "権限管理",
: "コード管理",
: "設定",
: "ログ管理",
: "プロジェクト管理",
: "製品管理",
};
for (const [korean, japanese] of Object.entries(translations)) {
if (baseName.includes(korean)) {
return baseName.replace(korean, japanese);
}
}
} else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = {
: "管理员",
: "用户管理",
: "菜单管理",
: "仪表板",
: "权限管理",
: "代码管理",
: "设置",
: "日志管理",
: "项目管理",
: "产品管理",
};
for (const [korean, chinese] of Object.entries(translations)) {
if (baseName.includes(korean)) {
return baseName.replace(korean, chinese);
}
}
}
return baseName;
};
const menuName = getDisplayText(menu);
const menuUrl = menu.menu_url || menu.MENU_URL;
console.log(
`메뉴 ${menuId}: 번역명="${menu.translated_name || menu.TRANSLATED_NAME}", 기본명="${menu.menu_name_kor || menu.MENU_NAME_KOR}", 최종명="${menuName}"`,
);
const children = convertMenuToUI(menus, userInfo, menuId);
return {
id: menuId,
title: menuName,
icon: getMenuIcon(menuName || ""),
path: menuUrl,
children: children.length > 0 ? children : undefined,
};
});
};
export default function MainLayout({ children }: MainLayoutProps) {
const router = useRouter();
const { adminMenus, userMenus, loading: menuLoading } = useMenu();
const { user, loading: authLoading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [expandedMenus, setExpandedMenus] = useState<string[]>([]);
// 사용자의 회사 코드에 따라 메뉴 필터링
const filterMenusByCompany = (menus: MenuItem[]) => {
console.log("=== 메뉴 필터링 시작 ===");
console.log("사용자 정보:", user);
console.log("사용자 회사 코드:", (user as ExtendedUserInfo)?.companyCode);
console.log("전체 메뉴 수:", menus.length);
console.log("현재 경로:", window.location.pathname);
// 모든 메뉴 표시 (원래 상태로 복원)
console.log("모든 메뉴 표시");
return menus;
};
// 활성 메뉴만 필터링하여 UI용으로 변환 (언어 변경 시 재계산)
const activeMenus = [...adminMenus, ...userMenus].filter((menu) => (menu.status || menu.STATUS) === "active");
const filteredMenus = filterMenusByCompany(activeMenus);
const menuItems = convertMenuToUI(filteredMenus, user);
// 디버깅 로그 추가
console.log("=== MainLayout 메뉴 디버깅 ===");
console.log("adminMenus 개수:", adminMenus.length);
console.log("userMenus 개수:", userMenus.length);
console.log("activeMenus 개수:", activeMenus.length);
console.log("filteredMenus 개수:", filteredMenus.length);
console.log("menuItems 개수:", menuItems.length);
// 번역 데이터 확인
if (menuItems.length > 0) {
console.log("첫 번째 메뉴 아이템:", {
id: menuItems[0].id,
title: menuItems[0].title,
hasChildren: !!menuItems[0].children,
});
}
if (adminMenus.length > 0) {
console.log("첫 번째 adminMenu:", adminMenus[0]);
}
if (userMenus.length > 0) {
console.log("첫 번째 userMenu:", userMenus[0]);
}
// useAuth 훅에서 인증 상태를 관리하므로 별도 인증 확인 불필요
// useEffect(() => {
// checkAuthStatus();
// }, []);
// 키보드 단축키로 사이드바 토글
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);
}, []);
const handleLogout = async () => {
await logout();
};
const toggleMenu = (menuId: string) => {
setExpandedMenus((prev) => (prev.includes(menuId) ? prev.filter((id) => id !== menuId) : [...prev, menuId]));
};
const handleMenuClick = (path?: string) => {
if (path) {
router.push(path);
}
};
if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<Shield className="mx-auto mb-4 h-12 w-12 animate-pulse text-slate-400" />
<p className="text-slate-600"> ...</p>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="flex h-screen flex-col bg-slate-50">
{/* 상단 헤더 */}
<header className="z-10 border-b border-slate-200 bg-white shadow-sm">
<div className="flex h-16 items-center justify-between px-4">
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="mr-3"
title="사이드바 토글 (Ctrl+B)"
>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
<div className="flex items-center">
<Shield className="mr-3 h-8 w-8 text-slate-900" />
<h1 className="text-xl font-semibold text-slate-900">PLM </h1>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center text-sm text-slate-600">
<User className="mr-2 h-4 w-4" />
<span className="font-medium">{user.userName}</span>
{user.deptName && <span className="ml-2 text-slate-400">({user.deptName})</span>}
</div>
<Button variant="outline" size="sm" onClick={handleLogout} className="text-slate-600 hover:text-slate-900">
<LogOut className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{/* 모바일 사이드바 오버레이 */}
{sidebarOpen && (
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* 왼쪽 사이드바 */}
<aside
className={`${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
} fixed top-16 left-0 z-40 flex h-[calc(100vh-4rem)] w-64 flex-col border-r border-slate-200 bg-white transition-transform duration-300 lg:relative lg:top-0 lg:z-auto lg:h-full lg:translate-x-0 lg:transform-none ${
sidebarOpen ? "lg:w-64" : "lg:w-16"
}`}
>
<div className="flex-1 overflow-y-auto py-4">
<nav className="space-y-1 px-3">
{menuItems.map((item) => (
<div key={item.id}>
<div
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
expandedMenus.includes(item.id)
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
onClick={() => {
if (item.children) {
toggleMenu(item.id);
} else {
handleMenuClick(item.path);
}
}}
>
<div className="flex items-center">
{item.icon}
{sidebarOpen && <span className="ml-3">{item.title}</span>}
</div>
{sidebarOpen && item.children && (
<div className="ml-auto">
{expandedMenus.includes(item.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
)}
</div>
{/* 서브메뉴 */}
{sidebarOpen && item.children && expandedMenus.includes(item.id) && (
<div className="mt-1 space-y-1 pl-6">
{item.children.map((child: any) => (
<div
key={child.id}
className="flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-900"
onClick={() => handleMenuClick(child.path)}
>
{child.icon}
<span className="ml-3">{child.title}</span>
</div>
))}
</div>
)}
</div>
))}
</nav>
</div>
{/* 사이드바 하단 영역 */}
<div className="space-y-3 border-t border-slate-200 p-3">
{/* 사용자 locale 정보 표시 */}
{sidebarOpen && (user as ExtendedUserInfo)?.locale && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500"></span>
<span className="text-xs text-slate-700">{(user as ExtendedUserInfo).locale!.toUpperCase()}</span>
</div>
)}
{/* 사이드바 토글 버튼 (데스크톱용) */}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden w-full justify-start lg:flex"
title={sidebarOpen ? "사이드바 접기" : "사이드바 펼치기"}
>
{sidebarOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
{sidebarOpen && <span className="ml-3"> </span>}
</Button>
</div>
</aside>
{/* 가운데 컨텐츠 영역 */}
<main className="flex-1 overflow-y-auto bg-slate-50">
<div className="h-full">{children}</div>
</main>
</div>
</div>
);
}

View File

@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, Settings } from "lucide-react";
import { LogOut, User } from "lucide-react";
interface UserDropdownProps {
user: any;
@ -50,12 +50,9 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<p className="text-muted-foreground text-xs leading-none font-semibold">
{user.deptName && user.positionName
? `${user.deptName}, ${user.positionName}`
: user.deptName || user.positionName || "정보 없음"}
: user.deptName || user.positionName || "부서 정보 없음"}
</p>
{/* 사진 상태 표시 */}
<p className="text-muted-foreground text-xs italic">
{user.photo ? "프로필 사진 있음" : "프로필 사진이 없습니다"}
</p>
</div>
</div>
</DropdownMenuLabel>
@ -64,11 +61,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span></span>

View File

@ -81,7 +81,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none hover:cursor-pointer data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}