증상: 모바일로 로그인 시 사이드바가 콘텐츠를 덮어 사용 불가능. 원인: 사이드바가 모든 폭에서 항상 정상 폭으로 자리잡음. [레이아웃] - 사이드바를 모바일에서 fixed + translate-x-full 로 화면 밖에 두고, mobileOpen=true 시 translate-x-0 슬라이드 인 (200ms transition) - 모바일 오버레이 배경 클릭 시 닫기 - lg 이상에서는 기존대로 좌측 고정 [헤더] - 모바일에서만 햄버거(≡) 버튼 노출 → setMobileOpen(true) - 사용자명 모바일 width 줄이고 부서명 숨김 (110px → sm 이상 200px) [사이드바] - 헤더 우측에 모바일 전용 X 버튼 추가 (lg:hidden) - 데스크탑 햄버거 토글은 hidden lg:flex 로 분리 - handleSubMenuClick 에서 setMobileOpen(false) 호출 → 메뉴 선택 시 자동 닫힘 [스토어] - mobileOpen 상태 + setMobileOpen 액션 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-18
@@ -3,43 +3,51 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { useMenuStore } from "@/store/menu-store";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
|
||||
// mainFS.jsp 대응 - 프레임셋 → Sidebar + Header + Content 레이아웃
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, fetchUser } = useAuthStore();
|
||||
const { mobileOpen, setMobileOpen } = useMenuStore();
|
||||
|
||||
useEffect(() => { fetchUser(); }, [fetchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isLoading && !user) router.push("/login");
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message="로딩 중..." />;
|
||||
}
|
||||
|
||||
if (isLoading) return <Loading message="로딩 중..." />;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* 사이드바 (menu.jsp 대응) */}
|
||||
<Sidebar />
|
||||
{/* 사이드바 — 데스크탑은 정상, 모바일은 오버레이로 등장 */}
|
||||
<div
|
||||
className={
|
||||
"fixed inset-y-0 left-0 z-50 shadow-2xl transition-transform duration-200 " +
|
||||
"lg:static lg:shadow-none lg:translate-x-0 " +
|
||||
(mobileOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0")
|
||||
}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* 모바일 오버레이 배경 — 사이드바 펼쳤을 때 어둡게 */}
|
||||
{mobileOpen && (
|
||||
<button
|
||||
aria-label="사이드바 닫기"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="fixed inset-0 bg-slate-900/40 z-40 lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 헤더 (header.jsp 대응) */}
|
||||
<Header />
|
||||
|
||||
{/* 콘텐츠 (contents_page iframe 대응) */}
|
||||
<main className="flex-1 overflow-hidden p-4 flex flex-col min-h-0">
|
||||
<main className="flex-1 overflow-auto p-3 sm:p-4 flex flex-col min-h-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { useMenuStore } from "@/store/menu-store";
|
||||
import { LogOut, User, BookOpen } from "lucide-react";
|
||||
import { LogOut, User, BookOpen, Menu as MenuIcon } from "lucide-react";
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { topMenus, activeTopMenu, fetchTopMenus, fetchSideMenus } = useMenuStore();
|
||||
const { topMenus, activeTopMenu, fetchTopMenus, fetchSideMenus, setMobileOpen } = useMenuStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopMenus();
|
||||
@@ -25,7 +25,16 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-white border-b border-gray-200 flex items-center px-3 sm:px-4 shrink-0 gap-2">
|
||||
{/* 좌측 여백만 — 모바일에서 사이드바 토글이 이 영역을 차지함 */}
|
||||
{/* 모바일 햄버거 — lg 이상에서는 숨김 */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden p-1.5 -ml-1 text-gray-600 hover:text-emerald-700 rounded"
|
||||
aria-label="메뉴 열기"
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</button>
|
||||
|
||||
{/* 좌측 여백 */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 매뉴얼 보기 (새 탭) */}
|
||||
@@ -43,12 +52,13 @@ export function Header() {
|
||||
{/* 우측: 사용자명(프로필 링크) + 로그아웃 */}
|
||||
<a
|
||||
href="/profile"
|
||||
className="flex items-center gap-1.5 text-xs sm:text-sm text-gray-600 hover:text-emerald-700 hover:underline pl-2 border-l border-gray-200"
|
||||
className="flex items-center gap-1.5 text-xs sm:text-sm text-gray-600 hover:text-emerald-700 hover:underline pl-2 border-l border-gray-200 min-w-0"
|
||||
title="회원정보 수정"
|
||||
>
|
||||
<User size={14} className="text-gray-400" />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{user?.userName} {user?.deptName ? `(${user.deptName})` : ""}
|
||||
<User size={14} className="text-gray-400 shrink-0" />
|
||||
<span className="truncate max-w-[110px] sm:max-w-[200px]">
|
||||
{user?.userName}
|
||||
<span className="hidden sm:inline">{user?.deptName ? ` (${user.deptName})` : ""}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
|
||||
Headset, Clock, Calculator, Coins, Truck, Settings,
|
||||
ClipboardCheck, Compass, GitBranch, Puzzle, Stamp, Folder,
|
||||
ChevronDown, Menu as MenuIcon,
|
||||
ChevronDown, Menu as MenuIcon, X,
|
||||
} from "lucide-react";
|
||||
|
||||
const ICON_COMPONENTS: Record<string, React.ElementType> = {
|
||||
@@ -34,7 +34,7 @@ export function Sidebar() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
sideMenus, activeSubMenu, isCollapsed,
|
||||
setActiveSubMenu, toggleCollapsed,
|
||||
setActiveSubMenu, toggleCollapsed, setMobileOpen,
|
||||
} = useMenuStore();
|
||||
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
|
||||
// 축소 모드 호버 팝업
|
||||
@@ -61,6 +61,7 @@ export function Sidebar() {
|
||||
const handleSubMenuClick = (menuObjId: string, menuUrl: string) => {
|
||||
setActiveSubMenu(menuObjId);
|
||||
setHoveredCategory(null); // 팝업 닫기
|
||||
setMobileOpen(false); // 모바일에서 메뉴 선택 시 자동 닫기
|
||||
const path = mapMenuUrl(menuUrl);
|
||||
if (path) router.push(path);
|
||||
};
|
||||
@@ -120,9 +121,18 @@ export function Sidebar() {
|
||||
) : (
|
||||
<img src="/momo-icon.svg" alt="MOMO" width={24} height={24} className="mx-auto" />
|
||||
)}
|
||||
{/* 모바일: X로 닫기, 데스크탑: 햄버거로 축소/확장 */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="lg:hidden text-[#8890a0] hover:text-white hover:bg-white/[0.08] rounded p-1 transition-colors shrink-0"
|
||||
aria-label="메뉴 닫기"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
className="text-[#8890a0] hover:text-white hover:bg-white/[0.08] rounded p-1 transition-colors shrink-0"
|
||||
className="hidden lg:flex text-[#8890a0] hover:text-white hover:bg-white/[0.08] rounded p-1 transition-colors shrink-0"
|
||||
aria-label="사이드바 축소/확장"
|
||||
>
|
||||
<MenuIcon size={16} />
|
||||
</button>
|
||||
|
||||
@@ -7,11 +7,14 @@ interface MenuState {
|
||||
activeTopMenu: string;
|
||||
activeSubMenu: string;
|
||||
isCollapsed: boolean;
|
||||
/** 모바일에서 사이드바 오버레이로 펼침 여부 */
|
||||
mobileOpen: boolean;
|
||||
setTopMenus: (menus: { OBJID: string; MENU_NAME_KOR: string }[]) => void;
|
||||
setSideMenus: (menus: MenuItem[]) => void;
|
||||
setActiveTopMenu: (id: string) => void;
|
||||
setActiveSubMenu: (id: string) => void;
|
||||
toggleCollapsed: () => void;
|
||||
setMobileOpen: (v: boolean) => void;
|
||||
fetchTopMenus: () => Promise<void>;
|
||||
fetchSideMenus: (menuObjId: string) => Promise<void>;
|
||||
}
|
||||
@@ -22,12 +25,14 @@ export const useMenuStore = create<MenuState>((set) => ({
|
||||
activeTopMenu: "",
|
||||
activeSubMenu: "",
|
||||
isCollapsed: false,
|
||||
mobileOpen: false,
|
||||
|
||||
setTopMenus: (topMenus) => set({ topMenus }),
|
||||
setSideMenus: (sideMenus) => set({ sideMenus }),
|
||||
setActiveTopMenu: (activeTopMenu) => set({ activeTopMenu }),
|
||||
setActiveSubMenu: (activeSubMenu) => set({ activeSubMenu }),
|
||||
toggleCollapsed: () => set((s) => ({ isCollapsed: !s.isCollapsed })),
|
||||
setMobileOpen: (v) => set({ mobileOpen: v }),
|
||||
|
||||
fetchTopMenus: async () => {
|
||||
const res = await fetch("/api/menu/top");
|
||||
|
||||
Reference in New Issue
Block a user