Files
sam-react-prod/src/components/layout/Sidebar.tsx
byeongcheolryu f92393f898 feat(WEB): 3depth 메뉴 구조 지원 및 CEO 대시보드 개선
- 사이드바 메뉴 3depth 이상 지원 (재귀 컴포넌트)
- menuTransform.ts: buildChildrenRecursive 함수 추가
- AuthenticatedLayout.tsx: findMenuRecursive + ancestorIds 배열로 경로 매칭
- Sidebar.tsx: depth별 스타일 (1depth: 아이콘+굵은텍스트, 2depth: 작은아이콘, 3depth: dot+작은텍스트)
- CEO 대시보드 상세 모달 및 카드 관리 개선
- 폴더블 기기 레이아웃 가이드 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:35:05 +09:00

299 lines
10 KiB
TypeScript

import { ChevronRight, Circle } from 'lucide-react';
import type { MenuItem } from '@/store/menuStore';
import { useEffect, useRef } from 'react';
interface SidebarProps {
menuItems: MenuItem[];
activeMenu: string;
expandedMenus: string[];
sidebarCollapsed: boolean;
isMobile: boolean;
onMenuClick: (menuId: string, path: string) => void;
onToggleSubmenu: (menuId: string) => void;
onCloseMobileSidebar?: () => void;
}
// 재귀적 메뉴 아이템 컴포넌트 Props
interface MenuItemComponentProps {
item: MenuItem;
depth: number; // 0: 1depth, 1: 2depth, 2: 3depth
activeMenu: string;
expandedMenus: string[];
sidebarCollapsed: boolean;
isMobile: boolean;
activeMenuRef: React.RefObject<HTMLDivElement | null>;
onMenuClick: (menuId: string, path: string) => void;
onToggleSubmenu: (menuId: string) => void;
onCloseMobileSidebar?: () => void;
}
// 재귀적 메뉴 아이템 컴포넌트 (3depth 이상 지원)
function MenuItemComponent({
item,
depth,
activeMenu,
expandedMenus,
sidebarCollapsed,
isMobile,
activeMenuRef,
onMenuClick,
onToggleSubmenu,
onCloseMobileSidebar,
}: MenuItemComponentProps) {
const IconComponent = item.icon;
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMenus.includes(item.id);
const isActive = activeMenu === item.id;
const handleClick = () => {
if (hasChildren) {
onToggleSubmenu(item.id);
} else {
onMenuClick(item.id, item.path);
if (isMobile && onCloseMobileSidebar) {
onCloseMobileSidebar();
}
}
};
// depth별 스타일 설정
// 1depth (depth=0): 아이콘 + 굵은 텍스트 + 배경색
// 2depth (depth=1): 작은 아이콘 + 일반 텍스트 + 왼쪽 보더
// 3depth (depth=2+): 점(dot) 아이콘 + 작은 텍스트 + 더 깊은 들여쓰기
const is1Depth = depth === 0;
const is2Depth = depth === 1;
const is3DepthOrMore = depth >= 2;
// 1depth 메뉴 렌더링
if (is1Depth) {
return (
<div
className="relative"
ref={isActive ? activeMenuRef : null}
>
{/* 메인 메뉴 버튼 */}
<button
onClick={handleClick}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-3 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
{/* 자식 메뉴 (재귀) */}
{hasChildren && isExpanded && !sidebarCollapsed && (
<div className="mt-1.5 ml-3 space-y-1 border-l-2 border-primary/20 pl-3">
{item.children?.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
)}
</div>
);
}
// 2depth 메뉴 렌더링
if (is2Depth) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3.5 w-3.5" />
</div>
)}
</button>
{/* 자식 메뉴 (3depth) */}
{hasChildren && isExpanded && (
<div className="mt-1 ml-2 space-y-0.5 border-l border-border/50 pl-2">
{item.children?.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
)}
</div>
);
}
// 3depth 이상 메뉴 렌더링 (점 아이콘 + 작은 텍스트)
if (is3DepthOrMore) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
}`} />
<span className="flex-1 text-xs text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3 w-3" />
</div>
)}
</button>
{/* 자식 메뉴 (4depth 이상 - 재귀) */}
{hasChildren && isExpanded && (
<div className="mt-0.5 ml-1.5 space-y-0.5 border-l border-border/30 pl-1.5">
{item.children?.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
)}
</div>
);
}
return null;
}
export default function Sidebar({
menuItems,
activeMenu,
expandedMenus,
sidebarCollapsed,
isMobile,
onMenuClick,
onToggleSubmenu,
onCloseMobileSidebar,
}: SidebarProps) {
// 활성 메뉴 자동 스크롤을 위한 ref
const activeMenuRef = useRef<HTMLDivElement | null>(null);
const menuContainerRef = useRef<HTMLDivElement | null>(null);
// 활성 메뉴가 변경될 때 자동 스크롤
useEffect(() => {
if (activeMenuRef.current && menuContainerRef.current) {
// 부드러운 스크롤로 활성 메뉴를 화면에 표시
activeMenuRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
}, [activeMenu]);
return (
<div className={`h-full flex flex-col clean-glass rounded-2xl overflow-hidden transition-all duration-300 ${
sidebarCollapsed ? 'sidebar-collapsed' : ''
}`}>
{/* 메뉴 */}
<div
ref={menuContainerRef}
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
sidebarCollapsed ? 'px-2 py-3' : 'px-3 py-4 md:px-4 md:py-4'
}`}
>
<div className={`transition-all duration-300 ${
sidebarCollapsed ? 'space-y-1.5 mt-4' : 'space-y-1.5 mt-3'
}`}>
{menuItems.map((item) => (
<MenuItemComponent
key={item.id}
item={item}
depth={0}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
</div>
</div>
);
}