ERP-node/frontend/components/layout/AppLayout.tsx

509 lines
17 KiB
TypeScript

"use client";
import { useState, Suspense, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } 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 { menuScreenApi } from "@/lib/api/screen";
import { toast } from "sonner";
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" />;
if (name.includes("화면관리") || name.includes("screen")) return <FileText 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,
};
};
function AppLayoutInner({ children }: AppLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { user, logout, refreshUserData } = useAuth();
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
const [isMobile, setIsMobile] = useState(false);
// 화면 크기 감지 및 사이드바 초기 상태 설정
useEffect(() => {
const checkIsMobile = () => {
const mobile = window.innerWidth < 1024; // lg 브레이크포인트
setIsMobile(mobile);
// 모바일에서만 사이드바를 닫음
if (mobile) {
setSidebarOpen(false);
} else {
setSidebarOpen(true);
}
};
checkIsMobile();
window.addEventListener("resize", checkIsMobile);
return () => window.removeEventListener("resize", checkIsMobile);
}, []);
// 프로필 관련 로직
const {
isModalOpen,
formData,
selectedImage,
isSaving,
departments,
alertModal,
closeAlert,
openProfileModal,
closeProfileModal,
updateFormData,
selectImage,
removeImage,
saveProfile,
} = useProfile(user, refreshUserData, refreshMenus);
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "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 = async (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try {
const menuObjid = menu.objid || menu.id;
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
// 관리자 모드 상태를 쿼리 파라미터로 전달
const screenPath = isAdminMode
? `/screens/${firstScreen.screenId}?mode=admin`
: `/screens/${firstScreen.screenId}`;
router.push(screenPath);
if (isMobile) {
setSidebarOpen(false);
}
return;
}
} catch (error) {
console.warn("할당된 화면 조회 실패:", error);
}
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") {
router.push(menu.url);
if (isMobile) {
setSidebarOpen(false);
}
} else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
}
}
};
// 모드 전환 핸들러
const handleModeSwitch = () => {
if (isAdminMode) {
router.push("/main");
} else {
router.push("/admin");
}
};
// 로그아웃 핸들러
const handleLogout = async () => {
try {
await logout();
router.push("/login");
} catch (error) {
// 로그아웃 실패 시 처리
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
return (
<div key={menu.id}>
<div
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
pathname === menu.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
: 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 min-w-0 flex-1 items-center">
{menu.icon}
<span className="ml-3 truncate" title={menu.name}>
{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 transition-colors hover:cursor-pointer ${
pathname === child.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
onClick={() => handleMenuClick(child)}
>
<div className="flex min-w-0 flex-1 items-center">
{child.icon}
<span className="ml-3 truncate" title={child.name}>
{child.name}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
// 사용자 정보가 없으면 로딩 표시
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center">
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 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-white">
{/* MainHeader 컴포넌트 사용 */}
<MainHeader
user={user}
onSidebarToggle={() => {
// 모바일에서만 토글 동작
if (isMobile) {
setSidebarOpen(!sidebarOpen);
}
}}
onProfileClick={openProfileModal}
onLogout={handleLogout}
/>
<div className="flex flex-1 pt-14">
{/* 모바일 사이드바 오버레이 */}
{sidebarOpen && isMobile && (
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* 왼쪽 사이드바 */}
<aside
className={`${
isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
: "relative top-0 z-auto translate-x-0"
} flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
>
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(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-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
}`}
>
{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="h-[calc(100vh-3.5rem)] min-w-0 flex-1 overflow-auto bg-white">
<div className="h-full w-full p-4">{children}</div>
</main>
</div>
{/* 프로필 수정 모달 */}
<ProfileModal
isOpen={isModalOpen}
user={user}
formData={formData}
selectedImage={selectedImage || ""}
isSaving={isSaving}
departments={departments}
alertModal={alertModal}
onClose={closeProfileModal}
onFormChange={updateFormData}
onImageSelect={selectImage}
onImageRemove={removeImage}
onSave={saveProfile}
onAlertClose={closeAlert}
/>
</div>
);
}
export function AppLayout({ children }: AppLayoutProps) {
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center">
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p>...</p>
</div>
</div>
}
>
<AppLayoutInner>{children}</AppLayoutInner>
</Suspense>
);
}