feat: [결재/공통] 결재함 + 레이아웃 + 캘린더 + 모바일 반응형
- 결재함 검사성적서 템플릿 기반 렌더링 + 결재 상신 - Sidebar/HeaderFavoritesBar 개선 - AuthenticatedLayout 모바일 반응형 - SearchableSelectionModal HTML 유효성 수정 - VacationManagement, 사원관리 정렬 옵션
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Star } from 'lucide-react';
|
||||
import { Bookmark, MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -20,14 +20,68 @@ import { useFavoritesStore } from '@/stores/favoritesStore';
|
||||
import { iconMap } from '@/lib/utils/menuTransform';
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
// "시스템 대시보드" 기준 텍스트 폭 (7글자 ≈ 80px)
|
||||
const TEXT_DEFAULT_MAX = 80;
|
||||
const TEXT_EXPANDED_MAX = 200;
|
||||
const TEXT_SHRUNK_MAX = 28;
|
||||
const OVERFLOW_BTN_WIDTH = 56;
|
||||
const GAP = 6;
|
||||
|
||||
interface HeaderFavoritesBarProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
/** 별 아이콘 드롭다운 (공간 부족 / 모바일 / 태블릿) */
|
||||
function StarDropdown({
|
||||
favorites,
|
||||
className,
|
||||
onItemClick,
|
||||
}: {
|
||||
favorites: FavoriteItem[];
|
||||
className?: string;
|
||||
onItemClick: (item: FavoriteItem) => void;
|
||||
}) {
|
||||
const getIcon = (name: string) => iconMap[name] || null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Bookmark className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
|
||||
const router = useRouter();
|
||||
const { favorites } = useFavoritesStore();
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chipWidthsRef = useRef<number[]>([]);
|
||||
const measuredRef = useRef(false);
|
||||
const [visibleCount, setVisibleCount] = useState(favorites.length);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
// 태블릿 감지 (768~1024)
|
||||
useEffect(() => {
|
||||
@@ -40,6 +94,70 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
// 즐겨찾기 변경 시 측정 리셋
|
||||
useEffect(() => {
|
||||
measuredRef.current = false;
|
||||
chipWidthsRef.current = [];
|
||||
setVisibleCount(favorites.length);
|
||||
}, [favorites.length]);
|
||||
|
||||
// 모바일/태블릿 ↔ 데스크탑 전환 시 측정 리셋
|
||||
useEffect(() => {
|
||||
if (!isMobile && !isTablet) {
|
||||
measuredRef.current = false;
|
||||
chipWidthsRef.current = [];
|
||||
setVisibleCount(favorites.length);
|
||||
}
|
||||
}, [isMobile, isTablet, favorites.length]);
|
||||
|
||||
// 데스크탑 동적 오버플로: 전체 chip 폭 측정 → 저장 → resize 시 재계산
|
||||
useEffect(() => {
|
||||
if (isMobile || isTablet) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const calculate = () => {
|
||||
// 최초: 전체 chip 렌더 상태에서 폭 저장
|
||||
if (!measuredRef.current) {
|
||||
const chips = container.querySelectorAll<HTMLElement>('[data-chip]');
|
||||
if (chips.length === favorites.length && chips.length > 0) {
|
||||
chipWidthsRef.current = Array.from(chips).map((c) => c.offsetWidth);
|
||||
measuredRef.current = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
const widths = chipWidthsRef.current;
|
||||
|
||||
// 공간 부족: chip 1개 + overflow 버튼도 안 들어가면 전부 드롭다운
|
||||
const minChipWidth = Math.min(...widths);
|
||||
if (containerWidth < minChipWidth + OVERFLOW_BTN_WIDTH + GAP) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < widths.length; i++) {
|
||||
const needed = totalWidth + widths[i] + (count > 0 ? GAP : 0);
|
||||
const hasMore = i < widths.length - 1;
|
||||
const reserve = hasMore ? OVERFLOW_BTN_WIDTH + GAP : 0;
|
||||
if (needed + reserve > containerWidth && count > 0) break;
|
||||
totalWidth = needed;
|
||||
count++;
|
||||
}
|
||||
setVisibleCount(count);
|
||||
};
|
||||
|
||||
requestAnimationFrame(calculate);
|
||||
const observer = new ResizeObserver(calculate);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile, isTablet, favorites.length]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: FavoriteItem) => {
|
||||
router.push(item.path);
|
||||
@@ -49,106 +167,121 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
|
||||
if (favorites.length === 0) return null;
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
return iconMap[iconName] || null;
|
||||
};
|
||||
const getIcon = (iconName: string) => iconMap[iconName] || null;
|
||||
|
||||
// 모바일 & 태블릿: 별 아이콘 드롭다운
|
||||
if (isMobile || isTablet) {
|
||||
// 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${
|
||||
isMobile
|
||||
? 'min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px]'
|
||||
: 'w-10 h-10'
|
||||
}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<StarDropdown
|
||||
favorites={favorites}
|
||||
onItemClick={handleClick}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] rounded-lg min-[320px]:rounded-xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운
|
||||
const DESKTOP_ICON_LIMIT = 8;
|
||||
|
||||
if (favorites.length > DESKTOP_ICON_LIMIT) {
|
||||
// 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지)
|
||||
if (isTablet) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-10 h-10 p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-end">
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} className="w-10 h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성)
|
||||
const visibleItems = favorites.slice(0, visibleCount);
|
||||
const overflowItems = favorites.slice(visibleCount);
|
||||
const showStarOnly = measuredRef.current && visibleCount === 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-2">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 p-0 flex items-center justify-center transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-w-0 flex items-center justify-end gap-1.5"
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{showStarOnly ? (
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} />
|
||||
) : (
|
||||
<>
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
const isHovered = hoveredId === item.id;
|
||||
const isOtherHovered = hoveredId !== null && !isHovered;
|
||||
|
||||
const textMaxWidth = isHovered
|
||||
? TEXT_EXPANDED_MAX
|
||||
: isOtherHovered
|
||||
? TEXT_SHRUNK_MAX
|
||||
: TEXT_DEFAULT_MAX;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-chip
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
className={`rounded-full text-white h-8 flex items-center overflow-hidden ${
|
||||
isOtherHovered ? 'px-2 gap-1 bg-blue-400/70' : 'px-3 gap-1.5 bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
transition: 'all 500ms cubic-bezier(0.25, 0.8, 0.25, 1)',
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span
|
||||
className="text-xs whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{
|
||||
maxWidth: textMaxWidth,
|
||||
transition: 'max-width 500ms cubic-bezier(0.25, 0.8, 0.25, 1), opacity 400ms ease',
|
||||
opacity: isOtherHovered ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="rounded-full bg-blue-500/80 hover:bg-blue-600 text-white h-8 px-2.5 gap-1 flex items-center shrink-0"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">+{overflowItems.length}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflowItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
|
||||
import { Bookmark, 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';
|
||||
@@ -159,7 +159,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user