- CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 API 연동 - dashboard-invalidation 유틸 추가 - 결재 문서작성/결재함 검사성적서 렌더링 개선 - HeaderFavoritesBar/Sidebar 레이아웃 수정 - 근태관리/휴가관리 뷰 보강 - LoginPage 개선 - 대시보드 transformer 수정 (receivable, status-issue)
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
import { Pin, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
|
import type { MenuItem } from '@/stores/menuStore';
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore';
|
|
import { getIconName } from '@/lib/utils/menuTransform';
|
|
import type { FavoriteItem } from '@/stores/favoritesStore';
|
|
import { toast } from 'sonner';
|
|
|
|
interface SidebarProps {
|
|
menuItems: MenuItem[];
|
|
activeMenu: string;
|
|
expandedMenus: string[];
|
|
sidebarCollapsed: boolean;
|
|
isMobile: boolean;
|
|
onMenuClick: (menuId: string, path: string) => void;
|
|
onToggleSubmenu: (menuId: string) => void;
|
|
onToggleAll?: () => void;
|
|
onCloseMobileSidebar?: () => void;
|
|
onExpandSidebar?: () => 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;
|
|
onExpandSidebar?: () => void;
|
|
}
|
|
|
|
// 재귀적 메뉴 아이템 컴포넌트 (3depth 이상 지원)
|
|
function MenuItemComponent({
|
|
item,
|
|
depth,
|
|
activeMenu,
|
|
expandedMenus,
|
|
sidebarCollapsed,
|
|
isMobile,
|
|
activeMenuRef,
|
|
onMenuClick,
|
|
onToggleSubmenu,
|
|
onCloseMobileSidebar,
|
|
onExpandSidebar,
|
|
}: 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 isLeaf = !hasChildren;
|
|
|
|
// 즐겨찾기 상태
|
|
const { toggleFavorite, isFavorite } = useFavoritesStore();
|
|
const isFav = isLeaf ? isFavorite(item.id) : false;
|
|
|
|
const handleStarClick = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const favItem: FavoriteItem = {
|
|
id: item.id,
|
|
label: item.label,
|
|
iconName: getIconName(item.icon),
|
|
path: item.path,
|
|
addedAt: Date.now(),
|
|
};
|
|
const result = toggleFavorite(favItem);
|
|
if (result === 'max_reached') {
|
|
toast.warning(`즐겨찾기는 최대 ${MAX_FAVORITES}개까지 등록할 수 있습니다.`);
|
|
}
|
|
}, [item, toggleFavorite]);
|
|
|
|
const handleClick = () => {
|
|
if (hasChildren) {
|
|
// 접힌 상태에서 카테고리 클릭 시 사이드바 자동 펼침
|
|
if (sidebarCollapsed && onExpandSidebar) {
|
|
onExpandSidebar();
|
|
}
|
|
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}
|
|
>
|
|
{/* 메인 메뉴 버튼 + 별표 래퍼 */}
|
|
<div className="flex items-center group/row">
|
|
<button
|
|
onClick={handleClick}
|
|
className={`flex-1 min-w-0 flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
|
|
sidebarCollapsed ? 'p-2.5 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>
|
|
{isLeaf && !sidebarCollapsed && (
|
|
<button
|
|
onClick={handleStarClick}
|
|
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
|
|
isFav
|
|
? 'opacity-100 text-yellow-500'
|
|
: isMobile
|
|
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
|
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
|
}`}
|
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
|
>
|
|
<Pin className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 자식 메뉴 (재귀) */}
|
|
{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}
|
|
onExpandSidebar={onExpandSidebar}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 2depth 메뉴 렌더링
|
|
if (is2Depth) {
|
|
return (
|
|
<div ref={isActive ? activeMenuRef : null}>
|
|
<div className="flex items-center group/row">
|
|
<button
|
|
onClick={handleClick}
|
|
className={`flex-1 min-w-0 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>
|
|
{isLeaf && (
|
|
<button
|
|
onClick={handleStarClick}
|
|
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
|
isFav
|
|
? 'opacity-100 text-yellow-500'
|
|
: isMobile
|
|
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
|
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
|
}`}
|
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
|
>
|
|
<Pin className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 자식 메뉴 (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}
|
|
onExpandSidebar={onExpandSidebar}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 3depth 이상 메뉴 렌더링 (점 아이콘 + 작은 텍스트)
|
|
if (is3DepthOrMore) {
|
|
return (
|
|
<div ref={isActive ? activeMenuRef : null}>
|
|
<div className="flex items-center group/row">
|
|
<button
|
|
onClick={handleClick}
|
|
className={`flex-1 min-w-0 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>
|
|
{isLeaf && (
|
|
<button
|
|
onClick={handleStarClick}
|
|
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
|
isFav
|
|
? 'opacity-100 text-yellow-500'
|
|
: isMobile
|
|
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
|
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
|
}`}
|
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
|
>
|
|
<Pin className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 자식 메뉴 (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}
|
|
onExpandSidebar={onExpandSidebar}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export default function Sidebar({
|
|
menuItems,
|
|
activeMenu,
|
|
expandedMenus,
|
|
sidebarCollapsed,
|
|
isMobile,
|
|
onMenuClick,
|
|
onToggleSubmenu,
|
|
onToggleAll,
|
|
onCloseMobileSidebar,
|
|
onExpandSidebar,
|
|
}: 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-2' : 'px-3 py-4 md:px-4 md:py-4'
|
|
}`}
|
|
>
|
|
{/* 전체 열기/닫기 토글 버튼 - 사이드바 펼침 상태에서만 표시 */}
|
|
{!sidebarCollapsed && onToggleAll && (
|
|
<button
|
|
onClick={onToggleAll}
|
|
className="w-full flex items-center space-x-2 px-3 py-2 rounded-lg text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-all duration-200"
|
|
>
|
|
{expandedMenus.length > 0 ? (
|
|
<>
|
|
<ChevronsDownUp className="h-3.5 w-3.5 flex-shrink-0" />
|
|
<span>모두 접기</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronsUpDown className="h-3.5 w-3.5 flex-shrink-0" />
|
|
<span>모두 펼치기</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
<div className={`transition-all duration-300 ${
|
|
sidebarCollapsed ? 'space-y-1 mt-2' : 'space-y-1.5 mt-1'
|
|
}`}>
|
|
{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}
|
|
onExpandSidebar={onExpandSidebar}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |