feat(모바일 반응형): 사이드바 햄버거 오버레이 + 자동 닫기
Deploy momo-erp / deploy (push) Successful in 51s

증상: 모바일로 로그인 시 사이드바가 콘텐츠를 덮어 사용 불가능.
원인: 사이드바가 모든 폭에서 항상 정상 폭으로 자리잡음.

[레이아웃]
- 사이드바를 모바일에서 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:
chpark
2026-05-08 09:23:27 +09:00
parent f85b2f17e0
commit 1502960151
4 changed files with 61 additions and 28 deletions
+26 -18
View File
@@ -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>
+17 -7
View File
@@ -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>
+13 -3
View File
@@ -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>
+5
View File
@@ -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");