Files
sam-react-prod/src/components/layout/HeaderFavoritesBar.tsx
유병철 00a6209347 feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선
- Sidebar/AuthenticatedLayout 소폭 수정
- ShipmentCreate, VehicleDispatch 출하 관련 개선
- WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선
- InspectionCreate 자재 입고검사 개선
- DailyReport, VendorDetail 회계 수정
- CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선
- useCEODashboard, expense transformer 정비
- DocumentViewer, PDF generate route 소폭 수정
- bill-prototype 개발 페이지 추가
- mockData 불필요 데이터 제거
2026-03-05 13:35:48 +09:00

289 lines
9.8 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bookmark, MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
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(() => {
const check = () => {
const w = window.innerWidth;
setIsTablet(w >= 768 && w < 1024);
};
check();
window.addEventListener('resize', check);
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);
},
[router]
);
if (favorites.length === 0) return null;
const getIcon = (iconName: string) => iconMap[iconName] || null;
// 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요)
if (isMobile) {
return (
<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"
/>
);
}
// 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지)
if (isTablet) {
return (
<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
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>
);
}