"use client"; import { useState, Suspense, useEffect, useCallback, useRef } 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, LogOut, User, Building2, FileCheck, Monitor, Plus, Edit3, Zap, Save, SlidersHorizontal, Sun, Moon, Bell, } from "lucide-react"; import { useDashboardStore } from "@/stores/dashboardStore"; import { useControlMode } from "@/components/control/hooks/useControlMode"; import { insertDashboard } from "@/lib/api/dashMenu"; import { CreateDashboardModal } from "@/components/dash/CreateDashboardModal"; import { useMenu } from "@/contexts/MenuContext"; import { useAuth } from "@/hooks/useAuth"; import { useProfile } from "@/hooks/useProfile"; import { MenuItem, menuApi } from "@/lib/api/menu"; import { menuScreenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { ProfileModal } from "./ProfileModal"; import { SettingsModal } from "./SettingsModal"; import { Logo } from "./Logo"; import { SideMenu } from "./SideMenu"; import { TabBar } from "./TabBar"; import { TabContent } from "./TabContent"; import { useTabStore } from "@/stores/tabStore"; import { ThemeToggle } from "./ThemeToggle"; import { TopNavBar } from "./TopNavBar"; import { animatedNavOrientationChange } from "@/lib/navOrientationTransition"; import { useTheme } from "next-themes"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { CompanySwitcher } from "@/components/admin/CompanySwitcher"; import { getIconComponent } from "@/components/admin/MenuIconPicker"; import { animatedThemeChange } from "@/lib/themeTransition"; interface ExtendedUserInfo { user_id: string; user_name: string; userNameEng?: string; userNameCn?: string; deptCode?: string; dept_name?: string; positionCode?: string; position_name?: string; email?: string; tel?: string; cellPhone?: string; user_type?: string; userTypeName?: string; authName?: string; partnerCd?: string; isAdmin: boolean; sabun?: string; photo?: string | null; company_code?: string; locale?: string; } interface AppLayoutProps { children: React.ReactNode; } const getMenuIcon = (menuName: string, dbIconName?: string | null) => { if (dbIconName) { const DbIcon = getIconComponent(dbIconName); if (DbIcon) return ; } const name = menuName.toLowerCase(); if (name.includes("대시보드") || name.includes("dashboard")) return ; if (name.includes("관리자") || name.includes("admin")) return ; if (name.includes("사용자") || name.includes("user")) return ; if (name.includes("프로젝트") || name.includes("project")) return ; if (name.includes("제품") || name.includes("product")) return ; if (name.includes("설정") || name.includes("setting")) return ; if (name.includes("로그") || name.includes("log")) return ; if (name.includes("메뉴") || name.includes("menu")) return ; if (name.includes("화면관리") || name.includes("screen")) return ; return ; }; const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => { const filteredMenus = menus .filter((menu) => String(menu.parent_obj_id ?? menu.PARENT_OBJ_ID ?? "0") === String(parentId ?? "0")) .filter((menu) => String(menu.status ?? menu.STATUS ?? "").toLowerCase() === "active") .sort((a, b) => Number(a.seq ?? a.SEQ ?? 0) - Number(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, parentPath)); }; const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => { const menuId = menu.objid ?? menu.OBJID; const getDisplayText = (m: MenuItem) => { if (m.translated_name || m.TRANSLATED_NAME) { return m.translated_name || m.TRANSLATED_NAME; } const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음"; 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 displayName = getDisplayText(menu); const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName; const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle); const menuUrl = menu.menu_url || menu.MENU_URL || "#"; const screenCode = menu.screen_code || menu.SCREEN_CODE || null; const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? ""); let screenId: number | null = null; const screensMatch = menuUrl.match(/^\/screens\/(\d+)/); if (screensMatch) { screenId = parseInt(screensMatch[1]); } return { id: menuId, objid: menuId, name: displayName, tabTitle, icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), url: menuUrl, screenCode, screen_id: screenId, menu_type: menuType, children: children.length > 0 ? children : undefined, hasChildren: children.length > 0, }; }; /** * 헤더 pop-out 툴 그룹 — show=false 전환 시 closing 클래스 후 320ms 뒤 unmount. * Ref: INVYONE Design System / ui_kits/app/dashboard-user.jsx (AnimatedHtoolGroup). */ function AnimatedHtoolGroup({ show, children }: { show: boolean; children: React.ReactNode }) { const [rendered, setRendered] = useState(show); const [closing, setClosing] = useState(false); useEffect(() => { if (show) { setRendered(true); setClosing(false); } else if (rendered) { setClosing(true); const t = window.setTimeout(() => { setRendered(false); setClosing(false); }, 320); return () => window.clearTimeout(t); } }, [show, rendered]); if (!rendered) return null; return {children}; } function AppLayoutInner({ children }: AppLayoutProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const { user, logout, refreshUserData } = useAuth(); const { user_menus: userMenus, admin_menus: adminMenus, loading, refreshMenus } = useMenu(); const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [tabsCollapsed, setTabsCollapsed] = useState(false); const [flyoutMenu, setFlyoutMenu] = useState<{ menu: any; rect: DOMRect } | null>(null); const [modeTransition, setModeTransition] = useState<"idle" | "out" | "in">("idle"); const [expandedMenus, setExpandedMenus] = useState>(new Set()); const [isMobile, setIsMobile] = useState(false); const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); const [currentCompanyName, setCurrentCompanyName] = useState(""); const [settingsOpen, setSettingsOpen] = useState(false); const { theme, setTheme: rawSetTheme } = useTheme(); // 메뉴 방향 (vertical: 사이드바 / horizontal: 헤더 내 TopNav). localStorage 유지. const [navOrientation, setNavOrientationRaw] = useState<"vertical" | "horizontal">("vertical"); const navOrientationRef = useRef<"vertical" | "horizontal">("vertical"); navOrientationRef.current = navOrientation; useEffect(() => { try { const saved = localStorage.getItem("invyone-nav-orientation"); if (saved === "horizontal" || saved === "vertical") setNavOrientationRaw(saved); } catch {} }, []); const setNavOrientation = useCallback((next: "vertical" | "horizontal") => { if (navOrientationRef.current === next) return; // Ghost-slide transition: 나가는 nav 를 축 방향으로 밀어내며 페이드, 새 nav 는 자체 enter 애니 실행 animatedNavOrientationChange(next, () => { setNavOrientationRaw(next); try { localStorage.setItem("invyone-nav-orientation", next); } catch {} }); }, []); // 대시보드 생성 (전역) + 제어/편집 (대시보드 페이지에서만 조건부 노출) const dashCreateOpen = useDashboardStore((s) => s.createOpen); const openDashCreate = useDashboardStore((s) => s.openCreate); const closeDashCreate = useDashboardStore((s) => s.closeCreate); const dashEditMode = useDashboardStore((s) => s.editMode); const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode); const setDashEditMode = useDashboardStore((s) => s.setEditMode); const openDashLib = useDashboardStore((s) => s.openLib); const dashControlActive = useControlMode((s) => s.active); const toggleDashControlMode = useControlMode((s) => s.toggleControlMode); const [dashCreateSubmitting, setDashCreateSubmitting] = useState(false); const handleDashCreateSubmit = useCallback(async (payload: { name: string; icon: string; is_personal: boolean }) => { setDashCreateSubmitting(true); try { const result = await insertDashboard(payload); try { await refreshMenus(); } catch { /* ignore */ } const newUrl = result?.menu_url ?? result?.MENU_URL; closeDashCreate(); toast.success(`"${payload.name}" 대시보드를 만들었습니다`); if (newUrl) router.push(newUrl); } catch (err) { toast.error("대시보드 생성 실패"); } finally { setDashCreateSubmitting(false); } }, [refreshMenus, closeDashCreate, router]); // 테마 전환 — 클릭 위치에서 원형으로 새 테마가 reveal (View Transitions API) const setNextTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => { if (theme === t) return; animatedThemeChange(t, rawSetTheme, e ? { x: e.clientX, y: e.clientY } : undefined); }, [theme, rawSetTheme]); // URL 직접 접근 시 탭 자동 열기 useEffect(() => { const store = useTabStore.getState(); const currentModeTabs = store[store.mode].tabs; if (currentModeTabs.length > 0) return; const screenMatch = pathname.match(/^\/screens\/(\d+)/); if (screenMatch) { const screenId = parseInt(screenMatch[1]); const menu_objid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; store.openTab({ type: "screen", title: `화면 ${screenId}`, screen_id: screenId, menu_objid }); return; } if (pathname.startsWith("/admin") && pathname !== "/admin") { store.setMode("admin"); store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", admin_url: pathname }); } }, []); // 현재 회사명 조회 (SUPER_ADMIN 전용) useEffect(() => { const fetchCurrentCompanyName = async () => { if ((user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN") { const companyCode = (user as ExtendedUserInfo)?.company_code; if (companyCode === "*") { setCurrentCompanyName("Invyone (최고 관리자)"); } else if (companyCode) { try { const response = await apiClient.get("/admin/companies/db"); if (response.data.success) { const company = response.data.data.find((c: any) => c.company_code === companyCode); setCurrentCompanyName(company?.company_name || companyCode); } } catch { setCurrentCompanyName(companyCode); } } } }; fetchCurrentCompanyName(); }, [(user as ExtendedUserInfo)?.company_code, (user as ExtendedUserInfo)?.user_type]); // 화면 크기 감지 및 사이드바 초기 상태 설정 useEffect(() => { const checkIsMobile = () => { const mobile = window.innerWidth < 1024; 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, isDriver, hasVehicle, driverInfo, driverFormData, updateDriverFormData, handleDriverStatusChange, handleDriverAccountDelete, handleDeleteVehicle, openVehicleRegisterModal, closeVehicleRegisterModal, isVehicleRegisterModalOpen, newVehicleData, updateNewVehicleData, handleRegisterVehicle, } = useProfile(user, refreshUserData, refreshMenus); const tabMode = useTabStore((s) => s.mode); const setTabMode = useTabStore((s) => s.setMode); const isAdminMode = tabMode === "admin"; const isPreviewMode = searchParams.get("preview") === "true"; const currentMenus = isAdminMode ? adminMenus : userMenus; const currentTabs = useTabStore((s) => s[s.mode].tabs); const currentActiveTabId = useTabStore((s) => s[s.mode].active_tab_id); const activeTab = currentTabs.find((t) => t.id === currentActiveTabId); const toggleMenu = (menuId: string) => { const newExpanded = new Set(expandedMenus); if (newExpanded.has(menuId)) { newExpanded.delete(menuId); } else { newExpanded.add(menuId); } setExpandedMenus(newExpanded); }; const { openTab } = useTabStore(); const handleMenuClick = async (menu: any) => { if (menu.hasChildren) { toggleMenu(menu.id); return; } const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; if (typeof window !== "undefined") { localStorage.setItem("currentMenuName", menuName); } const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); const isAdminMenu = menu.menu_type === "0"; console.log("[handleMenuClick] 메뉴 클릭:", { menuName, menuObjid, menu_type: menu.menu_type, isAdminMenu, screen_id: menu.screen_id, screenCode: menu.screenCode, url: menu.url, fullMenu: menu, }); // 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭 if (isAdminMenu) { if (menu.url && menu.url !== "#") { console.log("[handleMenuClick] → admin 탭:", menu.url); openTab({ type: "admin", title: menuName, admin_url: menu.url }); if (isMobile) setSidebarOpen(false); } else { toast.warning("이 메뉴에는 연결된 페이지가 없습니다."); } return; } // 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당 // 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭 if (menu.screen_id) { console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screen_id); openTab({ type: "screen", title: menuName, screen_id: menu.screen_id, menu_objid: menuObjid }); if (isMobile) setSidebarOpen(false); return; } // 2) screen_menu_assignments 테이블 조회 if (menuObjid) { try { console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid); const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); console.log("[handleMenuClick] → 조회 결과:", assignedScreens); if (assignedScreens.length > 0) { const assignedScreenId = assignedScreens[0].screen_id; console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreenId); openTab({ type: "screen", title: menuName, screen_id: assignedScreenId, menu_objid: menuObjid }); if (isMobile) setSidebarOpen(false); return; } } catch (err) { console.error("[handleMenuClick] 할당된 화면 조회 실패:", err); } } // 3) 대시보드 (사용자 메뉴) → 자동 부여된 /숫자 URL 로 이동 // - 회사별 시퀀스가 URL 그 자체. "/{seq}" 형태만 대시보드로 인식 if (menu.url && /^\/\d+$/.test(menu.url)) { console.log("[handleMenuClick] → 대시보드 페이지:", menu.url); router.push(menu.url); if (isMobile) setSidebarOpen(false); return; } // 4) 커스텀 페이지 URL (React 직접 구현 페이지) → admin 탭으로 렌더링 if (menu.url && menu.url !== "#" && !menu.url.startsWith("/screen/") && !menu.url.startsWith("/screens/")) { console.log("[handleMenuClick] → 커스텀 페이지 탭:", menu.url); openTab({ type: "admin", title: menuName, admin_url: menu.url }); if (isMobile) setSidebarOpen(false); return; } console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menu_type: menu.menu_type, url: menu.url, screen_id: menu.screen_id }); toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); }; const handleModeSwitch = useCallback((_e?: React.MouseEvent) => { if (modeTransition !== "idle") return; // Simplified mode transition — sidebar items stagger morph only, no burst/sweep/badge theatrics. // Phase 1 (0ms): sidebar items morph-out (stagger 20ms) // Phase 2 (180ms): React swaps mode, new items morph-in (stagger 20ms) // Phase 3 (~400ms): cleanup → idle setModeTransition("out"); // sidebar items morph-out (stagger 20ms) const oldItems = Array.from(document.querySelectorAll(".v5-side .v5-si")); oldItems.forEach((it, i) => { it.style.animationDelay = `${i * 20}ms`; it.classList.add("mode-morph-out"); }); setTimeout(() => { setTabMode(isAdminMode ? "user" : "admin"); setModeTransition("in"); requestAnimationFrame(() => { const newItems = Array.from(document.querySelectorAll(".v5-side .v5-si")); newItems.forEach((it, i) => { it.style.animationDelay = `${i * 20}ms`; it.classList.add("mode-morph-in"); }); }); setTimeout(() => { setModeTransition("idle"); document.querySelectorAll(".v5-side .v5-si").forEach((it) => { it.classList.remove("mode-morph-in", "mode-morph-out"); it.style.animationDelay = ""; }); }, 300); }, 180); }, [isAdminMode, setTabMode, modeTransition]); const handleLogout = async () => { try { await logout(); router.push("/login"); } catch { // 로그아웃 실패 } }; const handleMenuDragStart = (e: React.DragEvent, menu: any) => { if (menu.hasChildren) { e.preventDefault(); return; } e.dataTransfer.effectAllowed = "copy"; const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; const menuObjid = menu.objid || menu.id; const dragPayload = JSON.stringify({ menuName, menuObjid, url: menu.url }); e.dataTransfer.setData("application/tab-menu-pending", dragPayload); e.dataTransfer.setData("text/plain", menuName); }; // POP 모드 진입 핸들러 const handlePopModeClick = async () => { try { const response = await menuApi.getPopMenus(); if (response.success && response.data) { const { childMenus, landingMenu } = response.data; if (landingMenu?.menu_url) { router.push(landingMenu.menu_url); } else if (childMenus.length === 0) { toast.info("설정된 POP 화면이 없습니다"); } else if (childMenus.length === 1) { router.push(childMenus[0].menu_url); } else { router.push("/pop"); } } else { toast.info("설정된 POP 화면이 없습니다"); } } catch (error) { toast.error("POP 메뉴 조회 중 오류가 발생했습니다"); } }; // pathname + 활성 탭 기반 활성 메뉴 판별 (탭 네비게이션에서도 사이드바 활성 표시) const isMenuActive = useCallback( (menu: any): boolean => { if (pathname === menu.url) return true; if (!activeTab) return false; const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); if (activeTab.type === "admin" && activeTab.admin_url) { return menu.url === activeTab.admin_url; } if (activeTab.type === "screen") { if (activeTab.menu_objid != null && menuObjid === activeTab.menu_objid) return true; const { screen_id: activeTabScreenId } = activeTab; if (activeTabScreenId != null && menu.screen_id === activeTabScreenId) return true; } return false; }, [pathname, activeTab], ); // 플라이아웃 닫기 타이머 — hover out 후 작은 딜레이로 닫아 마우스가 버튼 ↔ 플라이아웃 이동 시 끊기지 않게 함 const flyoutCloseTimerRef = useRef | null>(null); const cancelFlyoutClose = useCallback(() => { if (flyoutCloseTimerRef.current) { clearTimeout(flyoutCloseTimerRef.current); flyoutCloseTimerRef.current = null; } }, []); const scheduleFlyoutClose = useCallback(() => { cancelFlyoutClose(); flyoutCloseTimerRef.current = setTimeout(() => setFlyoutMenu(null), 150); }, [cancelFlyoutClose]); // 접힌 사이드바에서 부모 메뉴 hover → 플라이아웃 오픈 const handleCollapsedMenuHover = useCallback((menu: any, e: React.MouseEvent) => { if (!sidebarCollapsed || !menu.hasChildren) return; cancelFlyoutClose(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutMenu({ menu, rect }); }, [sidebarCollapsed, cancelFlyoutClose]); // 접힌 사이드바에서 부모 메뉴 클릭 → 이미 hover 로 열려있으므로 swallow (leaf 만 동작) const handleCollapsedMenuClick = useCallback((menu: any, e: React.MouseEvent) => { if (!sidebarCollapsed || !menu.hasChildren) return false; e.stopPropagation(); return true; }, [sidebarCollapsed]); // 플라이아웃에서 메뉴 선택 const handleFlyoutSelect = useCallback((child: any) => { cancelFlyoutClose(); setFlyoutMenu(null); handleMenuClick(child); }, [cancelFlyoutClose]); // 언마운트 시 타이머 정리 useEffect(() => { return () => cancelFlyoutClose(); }, [cancelFlyoutClose]); // 메뉴 트리 렌더링 (v5 glassmorphism) const renderMenu = (menu: any, level: number = 0) => { const isExpanded = expandedMenus.has(menu.id); const isLeaf = !menu.hasChildren; return (
handleCollapsedMenuHover(menu, e)} onMouseLeave={() => { if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose(); }} >
handleMenuDragStart(e, menu)} className={`v5-si ${isMenuActive(menu) ? "on" : ""} ${level > 0 ? "ml-6" : ""}`} title={menu.name} onClick={(e) => { if (handleCollapsedMenuClick(menu, e)) return; handleMenuClick(menu); }} > {menu.icon} {menu.name?.trim() || "(이름 없음)"} {menu.hasChildren && !sidebarCollapsed && ( {isExpanded ? : } )}
{/* 플라이아웃 (접힌 상태에서만) */} {sidebarCollapsed && flyoutMenu?.menu.id === menu.id && menu.hasChildren && (
{menu.name}
{menu.children?.map((child: any) => (
handleFlyoutSelect(child)} > {child.icon} {child.name}
))}
)} {/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어. inner div 로 감싸야 grid 0fr trick 이 동작함 */} {!sidebarCollapsed && menu.hasChildren && (
{menu.children?.map((child: any, idx: number) => (
handleMenuDragStart(e, child)} className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`} style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }} onClick={() => handleMenuClick(child)} > {child.icon} {child.name}
))}
)}
); }; if (isPreviewMode) { return (
{children}
); } const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : []; // 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장. // expandedMenus 를 의존성에 넣으면 사용자가 수동으로 닫은 즉시 다시 펼쳐져 "닫히지 않는" 버그가 남. const autoExpandedForTabRef = useRef(null); useEffect(() => { if (!activeTab || uiMenus.length === 0) return; if (autoExpandedForTabRef.current === activeTab.id) return; autoExpandedForTabRef.current = activeTab.id; const toExpand: string[] = []; for (const menu of uiMenus) { if (menu.hasChildren && menu.children) { const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); if (hasActiveChild) toExpand.push(menu.id); } } if (toExpand.length > 0) { setExpandedMenus((prev) => { const next = new Set(prev); toExpand.forEach((id) => next.add(id)); return next; }); } }, [activeTab, uiMenus, isMenuActive]); if (!user) { return (

로딩중...

); } // Admin permission check const isAdmin = (user as ExtendedUserInfo)?.isAdmin || (user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" || (user as ExtendedUserInfo)?.user_type === "COMPANY_ADMIN" || (user as ExtendedUserInfo)?.user_type === "ADMIN" || (user as ExtendedUserInfo)?.user_type === "admin"; // Breadcrumb from active tab const breadcrumbText = activeTab?.title || "대시보드"; return ( <> {/* Theme fade overlay */}
{/* V5 Shell */}
{/* ===== Glass Header ===== */}
{/* Mobile hamburger */}
Invy.one
{navOrientation === "vertical" && ( <>
{isAdminMode ? "관리자" : "홈"} › {breadcrumbText}
관리자 모드
)} {navOrientation === "horizontal" && ( )}
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
{/* 대시보드 페이지: [+ 대시보드] 를 편집/제어와 같은 그룹에 묶어 한 덩어리로. 그 외 페이지: [+ 대시보드] 만 단독 노출. */} {pathname && !isAdminMode && /^\/\d+$/.test(pathname) ? (
{/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */}
) : ( )}
{/* ===== Tab Bar ===== */} setTabsCollapsed(!tabsCollapsed)} modeTransition={modeTransition} /> {/* Mobile overlay */} {sidebarOpen && isMobile && (
setSidebarOpen(false)} /> )} {/* ===== Body (sidebar + content) ===== */}
{/* Sidebar — horizontal 모드에서는 Mobile 용(햄버거) 로만 살리고 데스크톱 표시는 숨김 */} {(navOrientation === "vertical" || isMobile) && ( )} {/* Content area */} {/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
{pathname && !isAdminMode && ( pathname.startsWith('/dashboard') || pathname.startsWith('/dash') || pathname.startsWith('/admin/builder') || pathname.startsWith('/test-fc') || /^\/\d+$/.test(pathname) // /숫자 = 신규 대시보드(메뉴 시퀀스) 페이지 ) ? ( // ★ flex 컨테이너로 만들어서 안쪽 dash-shell이 height:100% 잘 먹도록
{children}
) : ( )}
회사 선택 관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다.
setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
{/* 전역 대시보드 생성 모달 — 헤더 "대시보드" 버튼에서 열림 */} ); } export function AppLayout({ children }: AppLayoutProps) { return (

로딩중...

} > {children} ); }