"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 */}
setSidebarOpen(!sidebarOpen)}>
Invy.one
{navOrientation === "vertical" && (
<>
{isAdminMode ? "관리자" : "홈"} › {breadcrumbText}
>
)}
{navOrientation === "horizontal" && (
)}
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
{/* 대시보드 페이지: [+ 대시보드] 를 편집/제어와 같은 그룹에 묶어 한 덩어리로.
그 외 페이지: [+ 대시보드] 만 단독 노출. */}
{pathname && !isAdminMode && /^\/\d+$/.test(pathname) ? (
대시보드
{/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */}
openDashLib()}
title="템플릿 추가"
>
템플릿
window.dispatchEvent(new CustomEvent("dash:save"))}
title="레이아웃 저장"
>
저장
{dashEditMode ? "편집중" : "편집"}
{
if (!dashControlActive) setDashEditMode(false);
toggleDashControlMode();
}}
title={dashControlActive ? "제어 종료" : "제어 모드 — 데이터 흐름 시각화"}
>
{dashControlActive ? "제어중" : "제어"}
) : (
대시보드
)}
{/* Theme toggle — single sun/moon icon (디자인시스템 준수, 2026-04-21) */}
setNextTheme(theme === "dark" ? "light" : "dark", e)}
title={theme === "dark" ? "라이트 모드로" : "다크 모드로"}
aria-label="테마 전환"
>
{theme === "dark" ? : }
{/* Mini tab icon (visible when tabs collapsed) */}
{tabsCollapsed && (
setTabsCollapsed(false)}
title="탭 펼치기"
>
{currentTabs.length}
)}
{/* Bell / Notifications */}
{/* Tweaks — 색상/테마 빠른 접근 (SettingsModal 오픈) */}
setSettingsOpen(true)}
title="Tweaks — 테마 / 색상"
aria-label="설정"
>
{isAdmin &&
}
{/* Admin toggle (gear ↔ home) */}
{isAdmin && (
handleModeSwitch(e)}
title={isAdminMode ? "홈으로" : "관리자 모드"}
aria-label={isAdminMode ? "홈으로" : "관리자 모드"}
>
{isAdminMode ? : }
)}
{/* Avatar dropdown */}
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
{user.user_name || "사용자"}
{user.email || user.user_id}
내 정보
setSettingsOpen(true)}>
설정
router.push("/admin/approvalBox")}>
결재함
POP 모드
로그아웃
{/* ===== 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}
);
}