2c0a97f2ba
- components/builder/* 폐기 (12-grid 미완성 빌더 14개 파일)
- components/template-builder/TemplateBuilder.tsx 신규
(자유배치 + 3뷰 + Zustand 스토어 + 드래그/리사이즈/히스토리/격자)
- admin/builder/page.tsx 진입점 전환 (BuilderLayout → TemplateBuilder)
- 타입 정리: FreePosition / TemplateComponent / ViewConfig / Card /
Dashboard / CardConnection 추가, 레거시(GridPosition/TemplateKind/
DEFAULT_COMPONENT_LAYOUTS/CANVAS_KEYWORDS) @deprecated 표기
- v2-* 마이그레이션 1차:
· 완전: v2-table-list (ResizeObserver), v2-table-search-widget (@container)
· 경량: button/input/select/date/text-display/card-display/aggregation-widget
(withContainerQuery HOC)
- 다크 모드 대응: Tailwind dark: variant 21패턴 71곳 치환
- /test-card-responsive PoC 검증 페이지
세션 후반 버그 픽스 (phase1-log §7):
- test-card-responsive (main) 그룹 밖 이동 (AppLayout 탭 시스템 회피)
- useRegistryPalette default_size {width,height}/{w,h} 포맷 정규화
- dark: variant 중복 체인 정리
검증: (A) 반응형 메커니즘, (B) TemplateBuilder UI 통과
(C) 기존 VEX 화면은 마이그레이션 미완 상태라 Phase 2 이후 개별 진행
스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md
로그: notes/gbpark/2026-04-10-card-engine-phase1-log.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1019 lines
39 KiB
TypeScript
1019 lines
39 KiB
TypeScript
"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,
|
|
} from "lucide-react";
|
|
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 { 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 { CosmicBackground } from "./CosmicBackground";
|
|
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 <DbIcon className="h-4 w-4" />;
|
|
}
|
|
|
|
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" />;
|
|
};
|
|
|
|
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): 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, 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,
|
|
};
|
|
};
|
|
|
|
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<Set<string>>(new Set());
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
|
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
|
const { theme, setTheme: rawSetTheme } = useTheme();
|
|
|
|
// 테마 전환 — 클릭 위치에서 원형으로 새 테마가 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) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리)
|
|
if (menu.url && menu.url.startsWith("/dashboard/")) {
|
|
console.log("[handleMenuClick] → 대시보드 탭:", menu.url);
|
|
openTab({ type: "admin", title: menuName, admin_url: 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<HTMLButtonElement>) => {
|
|
if (modeTransition !== "idle") return;
|
|
|
|
// 강화된 mode transition — 옵션 b/c/e/f 적용 (d 버튼 burst 제거, mode-fade overlay 도 제거):
|
|
// Phase 1 (0ms): 사이드바 items morph-out (stagger), 헤더 glow flash,
|
|
// breadcrumb swap-out, 이탈 시 admin badge zoom-out
|
|
// Phase 2 (350ms): React 가 모드 swap → 새 메뉴 morph-in (stagger), breadcrumb swap-in,
|
|
// 진입 시 admin badge zoom-in
|
|
// Phase 3 (~950ms): 모든 클래스 정리, idle 복귀
|
|
const goingToAdmin = !isAdminMode;
|
|
setModeTransition("out");
|
|
|
|
// (b) 사이드바 items morph-out — stagger
|
|
const oldItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si"));
|
|
oldItems.forEach((it, i) => {
|
|
it.style.animationDelay = `${i * 35}ms`;
|
|
it.classList.add("mode-morph-out");
|
|
});
|
|
|
|
// (c) 헤더 glow line flash
|
|
const hdrGlow = document.querySelector<HTMLElement>(".v5-hdr-glow");
|
|
if (hdrGlow) {
|
|
hdrGlow.classList.remove("mode-flash");
|
|
void hdrGlow.offsetWidth; // reflow → 재시작 가능하게
|
|
hdrGlow.classList.add("mode-flash");
|
|
}
|
|
|
|
// (d) 토글 버튼 burst 효과는 제거됨 — 가운데 밝아지는 게 거슬려서 통째로 뺌
|
|
|
|
// (e) breadcrumb swap-out
|
|
const bc = document.querySelector<HTMLElement>(".v5-hdr-bc");
|
|
bc?.classList.remove("mode-swap-in");
|
|
bc?.classList.add("mode-swap-out");
|
|
|
|
// (f) admin badge zoom-out (이탈 시에만)
|
|
if (!goingToAdmin) {
|
|
const badge = document.querySelector<HTMLElement>(".v5-admin-badge");
|
|
badge?.classList.remove("mode-zoom-in");
|
|
badge?.classList.add("mode-zoom-out");
|
|
}
|
|
|
|
setTimeout(() => {
|
|
// Phase 2: 모드 swap → React 재렌더 (새 메뉴/탭/breadcrumb)
|
|
setTabMode(isAdminMode ? "user" : "admin");
|
|
setModeTransition("in");
|
|
|
|
// 다음 프레임에 새 items 에 morph-in 적용
|
|
requestAnimationFrame(() => {
|
|
const newItems = Array.from(document.querySelectorAll<HTMLElement>(".v5-side .v5-si"));
|
|
newItems.forEach((it, i) => {
|
|
it.style.animationDelay = `${i * 45}ms`;
|
|
it.classList.add("mode-morph-in");
|
|
});
|
|
|
|
// breadcrumb swap-in (텍스트는 이미 새 모드 기준)
|
|
const newBc = document.querySelector<HTMLElement>(".v5-hdr-bc");
|
|
newBc?.classList.remove("mode-swap-out");
|
|
newBc?.classList.add("mode-swap-in");
|
|
|
|
// (f) admin badge zoom-in (진입 시에만)
|
|
if (goingToAdmin) {
|
|
const newBadge = document.querySelector<HTMLElement>(".v5-admin-badge");
|
|
newBadge?.classList.remove("mode-zoom-out");
|
|
newBadge?.classList.add("mode-zoom-in");
|
|
}
|
|
});
|
|
|
|
// Phase 3: 모든 애니메이션 클래스 정리
|
|
setTimeout(() => {
|
|
setModeTransition("idle");
|
|
document.querySelectorAll<HTMLElement>(".v5-side .v5-si").forEach((it) => {
|
|
it.classList.remove("mode-morph-in", "mode-morph-out");
|
|
it.style.animationDelay = "";
|
|
});
|
|
document.querySelector<HTMLElement>(".v5-hdr-bc")?.classList.remove("mode-swap-in", "mode-swap-out");
|
|
document.querySelector<HTMLElement>(".v5-admin-badge")?.classList.remove("mode-zoom-in", "mode-zoom-out");
|
|
}, 600);
|
|
}, 350);
|
|
}, [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<ReturnType<typeof setTimeout> | 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 (
|
|
<div
|
|
key={menu.id}
|
|
style={{ position: "relative" }}
|
|
onMouseEnter={(e) => handleCollapsedMenuHover(menu, e)}
|
|
onMouseLeave={() => {
|
|
if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose();
|
|
}}
|
|
>
|
|
<div
|
|
draggable={isLeaf && !sidebarCollapsed}
|
|
onDragStart={(e) => 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);
|
|
}}
|
|
>
|
|
<span className="ic">{menu.icon}</span>
|
|
<span className="truncate">{menu.name}</span>
|
|
{menu.hasChildren && !sidebarCollapsed && (
|
|
<span className="ml-auto">
|
|
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 플라이아웃 (접힌 상태에서만) */}
|
|
{sidebarCollapsed && flyoutMenu?.menu.id === menu.id && menu.hasChildren && (
|
|
<div
|
|
className="v5-side-flyout open"
|
|
style={{ top: 0 }}
|
|
onMouseEnter={cancelFlyoutClose}
|
|
onMouseLeave={scheduleFlyoutClose}
|
|
>
|
|
<div className="fly-title">{menu.name}</div>
|
|
{menu.children?.map((child: any) => (
|
|
<div
|
|
key={child.id}
|
|
className={`fly-item ${isMenuActive(child) ? "on" : ""}`}
|
|
onClick={() => handleFlyoutSelect(child)}
|
|
>
|
|
<span className="ic">{child.icon}</span>
|
|
<span>{child.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어. inner div 로 감싸야 grid 0fr trick 이 동작함 */}
|
|
{!sidebarCollapsed && menu.hasChildren && (
|
|
<div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}>
|
|
<div className="v5-submenu-inner">
|
|
{menu.children?.map((child: any, idx: number) => (
|
|
<div
|
|
key={child.id}
|
|
draggable={!child.hasChildren}
|
|
onDragStart={(e) => handleMenuDragStart(e, child)}
|
|
className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`}
|
|
style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }}
|
|
onClick={() => handleMenuClick(child)}
|
|
>
|
|
<span className="ic">{child.icon}</span>
|
|
<span className="truncate" title={child.name}>{child.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (isPreviewMode) {
|
|
return (
|
|
<div className="bg-background h-screen w-full overflow-auto p-4">{children}</div>
|
|
);
|
|
}
|
|
|
|
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
|
|
|
|
// 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장
|
|
useEffect(() => {
|
|
if (!activeTab || uiMenus.length === 0) return;
|
|
|
|
const toExpand: string[] = [];
|
|
for (const menu of uiMenus) {
|
|
if (menu.hasChildren && menu.children) {
|
|
const hasActiveChild = menu.children.some((child: any) => isMenuActive(child));
|
|
if (hasActiveChild && !expandedMenus.has(menu.id)) {
|
|
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, expandedMenus]);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<>
|
|
{/* Cosmic background */}
|
|
<CosmicBackground />
|
|
|
|
{/* Theme fade overlay */}
|
|
<div className="v5-theme-fade" id="v5-theme-fade" />
|
|
|
|
{/* V5 Shell */}
|
|
<div className={`v5-shell ${isAdminMode ? "v5-admin-mode" : ""}`}>
|
|
{/* ===== Glass Header ===== */}
|
|
<header className="v5-hdr">
|
|
<div className="v5-hdr-l">
|
|
{/* Mobile hamburger */}
|
|
<button className="v5-mobile-toggle" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
|
<Menu size={16} />
|
|
</button>
|
|
<div className="v5-hdr-logo">Invy.one</div>
|
|
<div className="v5-hdr-bc">
|
|
{isAdminMode ? "관리자" : "홈"} › <b>{breadcrumbText}</b>
|
|
</div>
|
|
<div className="v5-admin-badge">
|
|
<div className="badge-dot" />
|
|
관리자 모드
|
|
</div>
|
|
</div>
|
|
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
|
|
<div className="v5-hdr-glow" />
|
|
<div className="v5-hdr-r">
|
|
{/* Theme pill */}
|
|
<div className="v5-pill">
|
|
<button className={theme !== "dark" ? "on" : ""} onClick={(e) => setNextTheme("light", e)}>Light</button>
|
|
<button className={theme === "dark" ? "on" : ""} onClick={(e) => setNextTheme("dark", e)}>Dark</button>
|
|
</div>
|
|
|
|
{/* Mini tab icon (visible when tabs collapsed) */}
|
|
{tabsCollapsed && (
|
|
<button
|
|
className="v5-tab-mini visible"
|
|
onClick={() => setTabsCollapsed(false)}
|
|
title="탭 펼치기"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="9"/></svg>
|
|
<span className="tab-count">{currentTabs.length}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Bell / Notifications */}
|
|
<button className="v5-bell">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
|
<div className="v5-bell-dot" />
|
|
</button>
|
|
|
|
{/* Admin toggle (gear ↔ home) — 이벤트 전달해서 burst origin 으로 사용 */}
|
|
{isAdmin && (
|
|
<button className="v5-admin-btn" onClick={(e) => handleModeSwitch(e)} title={isAdminMode ? "홈으로" : "관리자"}>
|
|
<Settings size={14} className="ic-gear" />
|
|
<Home size={14} className="ic-home" />
|
|
<span className="v5-admin-label">{isAdminMode ? "홈으로" : "관리자"}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Avatar dropdown */}
|
|
<div className="v5-avatar-w">
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="v5-avatar">
|
|
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="v5-avatar-dd-content w-56" align="end">
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="bg-gradient-to-br from-[var(--v5-primary)] to-[var(--v5-cyan)] flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white">
|
|
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
|
|
</div>
|
|
<div className="flex flex-col space-y-1">
|
|
<p className="text-sm leading-none font-medium">{user.user_name || "사용자"}</p>
|
|
<p className="text-muted-foreground text-xs leading-none">{user.email || user.user_id}</p>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={openProfileModal}>
|
|
<User className="mr-2 h-4 w-4" />
|
|
<span>내 정보</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
|
<FileCheck className="mr-2 h-4 w-4" />
|
|
<span>결재함</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handlePopModeClick}>
|
|
<Monitor className="mr-2 h-4 w-4" />
|
|
<span>POP 모드</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleLogout} className="text-red-500 focus:text-red-500">
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>로그아웃</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ===== Tab Bar ===== */}
|
|
<TabBar collapsed={tabsCollapsed} onToggleCollapse={() => setTabsCollapsed(!tabsCollapsed)} modeTransition={modeTransition} />
|
|
|
|
{/* Mobile overlay */}
|
|
{sidebarOpen && isMobile && (
|
|
<div className="v5-side-overlay open" onClick={() => setSidebarOpen(false)} />
|
|
)}
|
|
|
|
{/* ===== Body (sidebar + content) ===== */}
|
|
<div className="v5-body">
|
|
{/* Sidebar */}
|
|
<aside className={`v5-side v5-side-anim ${isAdminMode ? "v5-admin-side" : ""} ${sidebarCollapsed ? "collapsed" : ""} ${isMobile ? (sidebarOpen ? "mobile-open" : "") : ""} ${modeTransition === "out" ? "slide-out" : modeTransition === "in" ? "slide-in" : ""}`}
|
|
style={isMobile ? { position: "fixed", left: 0, top: 0, bottom: 0, zIndex: 30, paddingTop: "60px", width: "260px", transform: sidebarOpen ? "none" : "translateX(-100%)" } : undefined}
|
|
>
|
|
{/* SUPER_ADMIN company info */}
|
|
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && !sidebarCollapsed && (
|
|
<div style={{ padding: ".5rem .6rem", marginBottom: ".25rem" }}>
|
|
<div className="flex items-center gap-2 rounded-lg p-2" style={{ background: "var(--v5-surface)", border: "1px solid var(--v5-glass-border)", borderRadius: "10px", fontSize: ".7rem" }}>
|
|
<Building2 size={14} style={{ color: "var(--v5-primary)", flexShrink: 0 }} />
|
|
<div className="min-w-0 flex-1">
|
|
<p style={{ fontSize: ".55rem", color: "var(--v5-text-muted)" }}>현재 관리 회사</p>
|
|
<p className="truncate font-semibold" style={{ fontSize: ".72rem", color: "var(--v5-text)" }}>{currentCompanyName || "로딩 중..."}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Menu items */}
|
|
<div className={`flex-1 ${sidebarCollapsed ? "overflow-visible" : "overflow-y-auto"}`} style={{ padding: "0" }}>
|
|
<nav style={{ display: "flex", flexDirection: "column", gap: "1px", overflow: sidebarCollapsed ? "visible" : undefined }}>
|
|
{loading ? (
|
|
<div className="animate-pulse space-y-2 p-3">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="h-8 rounded" style={{ background: "var(--v5-surface)" }} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
uiMenus.map((menu) => renderMenu(menu))
|
|
)}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Sidebar toggle */}
|
|
{!isMobile && (
|
|
<button className="v5-side-toggle" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: sidebarCollapsed ? "rotate(180deg)" : "none", transition: "transform .3s" }}>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
<span>접기</span>
|
|
</button>
|
|
)}
|
|
</aside>
|
|
|
|
{/* Content area */}
|
|
{/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
|
|
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
{pathname && (
|
|
pathname.startsWith('/dash') ||
|
|
pathname.startsWith('/admin/builder') ||
|
|
pathname.startsWith('/test-fc')
|
|
) ? (
|
|
// ★ flex 컨테이너로 만들어서 안쪽 dash-shell이 height:100% 잘 먹도록
|
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
{children}
|
|
</div>
|
|
) : (
|
|
<TabContent />
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<ProfileModal
|
|
isOpen={isModalOpen}
|
|
user={user}
|
|
formData={formData}
|
|
selectedImage={selectedImage || ""}
|
|
isSaving={isSaving}
|
|
departments={departments}
|
|
alertModal={alertModal}
|
|
isDriver={isDriver}
|
|
hasVehicle={hasVehicle}
|
|
driverInfo={driverInfo}
|
|
driverFormData={driverFormData}
|
|
onDriverFormChange={updateDriverFormData}
|
|
onDriverStatusChange={handleDriverStatusChange}
|
|
onDriverAccountDelete={handleDriverAccountDelete}
|
|
onDeleteVehicle={handleDeleteVehicle}
|
|
onOpenVehicleRegisterModal={openVehicleRegisterModal}
|
|
isVehicleRegisterModalOpen={isVehicleRegisterModalOpen}
|
|
newVehicleData={newVehicleData}
|
|
onCloseVehicleRegisterModal={closeVehicleRegisterModal}
|
|
onNewVehicleDataChange={updateNewVehicleData}
|
|
onRegisterVehicle={handleRegisterVehicle}
|
|
onClose={closeProfileModal}
|
|
onFormChange={updateFormData}
|
|
onImageSelect={selectImage}
|
|
onImageRemove={removeImage}
|
|
onSave={saveProfile}
|
|
onAlertClose={closeAlert}
|
|
/>
|
|
|
|
<Dialog open={showCompanySwitcher} onOpenChange={setShowCompanySwitcher}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">회사 선택</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="mt-4">
|
|
<CompanySwitcher onClose={() => setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|