refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선
- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration) - store → stores 디렉토리 이동 및 favoritesStore 추가 - dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리 - Sidebar 리팩토링 및 HeaderFavoritesBar 추가 - DashboardSwitcher 컴포넌트 추가 - 백업 파일(.v1-backup) 및 불필요 코드 정리 - InspectionPreviewModal 레이아웃 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMenuStore, type MenuItem } from '@/store/menuStore';
|
||||
import { useMenuStore, type MenuItem } from '@/stores/menuStore';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
|
||||
209
src/components/layout/HeaderFavoritesBar.tsx
Normal file
209
src/components/layout/HeaderFavoritesBar.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { 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';
|
||||
|
||||
type DisplayMode = 'full' | 'icon-only' | 'overflow';
|
||||
|
||||
interface HeaderFavoritesBarProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
|
||||
const router = useRouter();
|
||||
const { favorites } = useFavoritesStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('full');
|
||||
|
||||
// 반응형: ResizeObserver로 컨테이너 너비 감지
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setDisplayMode('overflow');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
if (width < 300 || favorites.length > 4) {
|
||||
setDisplayMode('overflow');
|
||||
} else if (width < 600) {
|
||||
setDisplayMode('icon-only');
|
||||
} else {
|
||||
setDisplayMode('full');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile, favorites.length]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: FavoriteItem) => {
|
||||
router.push(item.path);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
if (favorites.length === 0) return null;
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
const Icon = iconMap[iconName];
|
||||
return Icon || null;
|
||||
};
|
||||
|
||||
// 모바일: 최대 2개 아이콘 + 나머지 드롭다운
|
||||
if (isMobile) {
|
||||
const visible = favorites.slice(0, 2);
|
||||
const overflow = favorites.slice(2);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-0.5 sm:space-x-1">
|
||||
{visible.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
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] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title={item.label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{overflow.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
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] p-0 rounded-lg min-[320px]:rounded-xl bg-slate-600 hover:bg-slate-700 text-white flex items-center justify-center"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflow.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>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱
|
||||
const visibleCount = displayMode === 'overflow' ? 3 : favorites.length;
|
||||
const visibleItems = favorites.slice(0, visibleCount);
|
||||
const overflowItems = favorites.slice(visibleCount);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div ref={containerRef} className="flex items-center space-x-2">
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
|
||||
if (displayMode === 'full') {
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// icon-only 또는 overflow의 visible 부분
|
||||
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 p-2 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>
|
||||
);
|
||||
})}
|
||||
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl p-2 flex items-center justify-center"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</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,6 +1,9 @@
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||
import type { MenuItem } from '@/store/menuStore';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useFavoritesStore } from '@/stores/favoritesStore';
|
||||
import { getIconName } from '@/lib/utils/menuTransform';
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
interface SidebarProps {
|
||||
menuItems: MenuItem[];
|
||||
@@ -45,6 +48,24 @@ function MenuItemComponent({
|
||||
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(),
|
||||
};
|
||||
toggleFavorite(favItem);
|
||||
}, [item, toggleFavorite]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasChildren) {
|
||||
@@ -72,48 +93,63 @@ function MenuItemComponent({
|
||||
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-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'
|
||||
{/* 메인 메뉴 버튼 + 별표 래퍼 */}
|
||||
<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" : "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
|
||||
? "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'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 (재귀) */}
|
||||
{hasChildren && isExpanded && !sidebarCollapsed && (
|
||||
@@ -143,24 +179,39 @@ function MenuItemComponent({
|
||||
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>
|
||||
<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'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 (3depth) */}
|
||||
{hasChildren && isExpanded && (
|
||||
@@ -190,26 +241,41 @@ function MenuItemComponent({
|
||||
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>
|
||||
<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'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 (4depth 이상 - 재귀) */}
|
||||
{hasChildren && isExpanded && (
|
||||
|
||||
Reference in New Issue
Block a user