admin 레이아웃 수정 #4
|
|
@ -0,0 +1,3 @@
|
|||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,90 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Users, Shield, Settings, BarChart3 } from "lucide-react";
|
||||
/**
|
||||
* 관리자 메인 페이지 (회사관리)
|
||||
* 단순한 토큰 확인만 수행
|
||||
* 관리자 메인 페이지
|
||||
*/
|
||||
export default function AdminPage() {
|
||||
const [tokenInfo, setTokenInfo] = useState<any>({});
|
||||
const [isAuthorized, setIsAuthorized] = useState<boolean | null>(null);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 관리자 기능 카드들 */}
|
||||
<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>
|
||||
|
||||
useEffect(() => {
|
||||
console.log("=== AdminPage 시작 ===");
|
||||
<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>
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
console.log("localStorage 토큰:", token ? "존재" : "없음");
|
||||
<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>
|
||||
|
||||
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>
|
||||
</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="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-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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 메인 페이지 컴포넌트
|
||||
|
|
@ -12,23 +10,6 @@ import { RefreshCw } from "lucide-react";
|
|||
export default function MainPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">대시보드</h1>
|
||||
<p className="text-muted-foreground">PLM 시스템의 주요 현황을 확인하세요</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
console.log("refresh");
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{/* Welcome Message */}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
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="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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useEffect, ReactNode, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,407 @@
|
|||
"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";
|
||||
import { PageHeader } from "./PageHeader";
|
||||
import { getPageInfo } from "@/constants/pageInfo";
|
||||
|
||||
// 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="flex flex-col items-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">
|
||||
<PageHeader title={getPageInfo(pathname).title} description={getPageInfo(pathname).description} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 상단 헤더 컴포넌트
|
||||
* 제목, 설명, 추가 버튼 등을 표시
|
||||
*/
|
||||
export function PageHeader({ title, description }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{title}</h1>
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* 페이지별 제목과 설명 정보
|
||||
*/
|
||||
export interface PageInfo {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const PAGE_INFO: Record<string, PageInfo> = {
|
||||
// 메인 대시보드
|
||||
"/main": {
|
||||
title: "대시보드",
|
||||
description: "PLM 시스템의 주요 현황을 확인하세요",
|
||||
},
|
||||
|
||||
// 관리자 페이지들
|
||||
"/admin": {
|
||||
title: "관리자 대시보드",
|
||||
description: "시스템 관리 및 모니터링",
|
||||
},
|
||||
"/admin/company": {
|
||||
title: "회사 관리",
|
||||
description: "회사 정보를 등록하고 관리합니다",
|
||||
},
|
||||
"/admin/userMng": {
|
||||
title: "사용자 관리",
|
||||
description: "시스템 사용자를 관리합니다",
|
||||
},
|
||||
"/admin/menu": {
|
||||
title: "메뉴 관리",
|
||||
description: "시스템 메뉴를 관리합니다",
|
||||
},
|
||||
"/admin/i18n": {
|
||||
title: "다국어 관리",
|
||||
description: "다국어 번역을 관리합니다",
|
||||
},
|
||||
"/admin/tableMng": {
|
||||
title: "테이블 타입 관리",
|
||||
description: "데이터베이스 테이블 타입을 관리합니다",
|
||||
},
|
||||
|
||||
// 기타 페이지들
|
||||
"/multilang": {
|
||||
title: "다국어 설정",
|
||||
description: "언어 설정을 변경합니다",
|
||||
},
|
||||
"/dashboard": {
|
||||
title: "대시보드",
|
||||
description: "PLM 시스템 현황",
|
||||
},
|
||||
|
||||
// 기본값 (매핑되지 않은 페이지)
|
||||
default: {
|
||||
title: "PLM 솔루션",
|
||||
description: "제품 수명 주기 관리 시스템",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 경로에 맞는 페이지 정보를 반환
|
||||
*/
|
||||
export function getPageInfo(pathname: string): PageInfo {
|
||||
return PAGE_INFO[pathname] || PAGE_INFO["default"];
|
||||
}
|
||||
|
|
@ -49,6 +49,30 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
|||
setLoading(true);
|
||||
console.log("=== MenuContext API 호출 시작 ===");
|
||||
|
||||
// 사용자 로케일이 로드될 때까지 잠시 대기
|
||||
let retryCount = 0;
|
||||
const maxRetries = 20; // 최대 2초 대기 (100ms * 20)
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
if (typeof window !== "undefined") {
|
||||
const hasGlobalLang = !!(window as any).__GLOBAL_USER_LANG;
|
||||
const hasStoredLang = !!localStorage.getItem("userLocale");
|
||||
|
||||
if (hasGlobalLang || hasStoredLang) {
|
||||
console.log("✅ 사용자 로케일 로드 완료, 메뉴 API 호출 진행");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`⏳ 사용자 로케일 로드 대기 중... (${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.warn("⚠️ 사용자 로케일 로드 타임아웃, 기본값으로 진행");
|
||||
}
|
||||
|
||||
// 관리자 메뉴와 사용자 메뉴를 병렬로 로드
|
||||
const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const eslintConfig = [
|
|||
"react/no-unescaped-entities": "warn",
|
||||
// 기타 자주 발생하는 오류들을 경고로 변경
|
||||
"prefer-const": "warn",
|
||||
"no-unused-vars": "warn",
|
||||
"no-unused-vars": "off", // TypeScript에서는 @typescript-eslint/no-unused-vars 사용
|
||||
// Prettier와 일치하는 quote 규칙
|
||||
quotes: ["warn", "double", { avoidEscape: true }],
|
||||
semi: ["warn", "always"],
|
||||
|
|
|
|||
|
|
@ -61,9 +61,29 @@ apiClient.interceptors.request.use(
|
|||
|
||||
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
|
||||
if (config.method?.toUpperCase() === "GET") {
|
||||
// 전역 언어 상태에서 현재 언어 가져오기 (DB 값 그대로 사용)
|
||||
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "KR" : "KR";
|
||||
console.log("🌐 API 요청 시 언어 정보:", currentLang);
|
||||
// 우선순위: 전역 변수 > localStorage > 기본값
|
||||
let currentLang = "KR"; // 기본값
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// 1순위: 전역 변수에서 확인
|
||||
if ((window as any).__GLOBAL_USER_LANG) {
|
||||
currentLang = (window as any).__GLOBAL_USER_LANG;
|
||||
}
|
||||
// 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시)
|
||||
else {
|
||||
const storedLocale = localStorage.getItem("userLocale");
|
||||
if (storedLocale) {
|
||||
currentLang = storedLocale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🌐 API 요청 시 언어 정보:", {
|
||||
currentLang,
|
||||
globalVar: (window as any).__GLOBAL_USER_LANG,
|
||||
localStorage: typeof window !== "undefined" ? localStorage.getItem("userLocale") : null,
|
||||
url: config.url,
|
||||
});
|
||||
|
||||
if (config.params) {
|
||||
config.params.userLang = currentLang;
|
||||
|
|
|
|||
Loading…
Reference in New Issue