feat: React 프론트엔드 기능 대폭 확장
- 월별근무표: 휴일/근무일 관리, 자동 초기화 - 메일양식: 템플릿 CRUD, To/CC/BCC 설정 - 그룹정보: 부서 관리, 비트 연산 기반 권한 설정 - 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정 - 웹소켓 메시지 type 충돌 버그 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
450
Project/frontend/src/components/layout/Header.tsx
Normal file
450
Project/frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CheckSquare,
|
||||
Clock as ClockIcon,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Code,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Package,
|
||||
User,
|
||||
Users,
|
||||
CalendarDays,
|
||||
Mail,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||
import { AmkorLogo } from './AmkorLogo';
|
||||
|
||||
interface HeaderProps {
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path?: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface SubMenu {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
type: 'link' | 'submenu' | 'action';
|
||||
path?: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
submenu?: SubMenu;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface DropdownMenuConfig {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
// 일반 메뉴 항목
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/jobreport', icon: FileText, label: '업무일지' },
|
||||
{ path: '/project', icon: FolderKanban, label: '프로젝트' },
|
||||
{ path: '/todo', icon: CheckSquare, label: '할일' },
|
||||
{ path: '/kuntae', icon: ClockIcon, label: '근태' },
|
||||
];
|
||||
|
||||
// 드롭다운 메뉴 (2단계 지원)
|
||||
const dropdownMenus: DropdownMenuConfig[] = [
|
||||
{
|
||||
label: '공용정보',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
|
||||
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: Users,
|
||||
label: '사용자',
|
||||
submenu: {
|
||||
label: '사용자',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ icon: User, label: '정보', action: 'userInfo' },
|
||||
{ path: '/user/list', icon: Users, label: '목록' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||
{ type: 'link', path: '/user-group', icon: Shield, label: '그룹정보' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function DropdownNavMenu({
|
||||
menu,
|
||||
onItemClick,
|
||||
onAction
|
||||
}: {
|
||||
menu: DropdownMenuConfig;
|
||||
onItemClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSubItemClick = (subItem: NavItem) => {
|
||||
setIsOpen(false);
|
||||
setActiveSubmenu(null);
|
||||
if (subItem.action) {
|
||||
onAction?.(subItem.action);
|
||||
}
|
||||
onItemClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
|
||||
isOpen
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<menu.icon className="w-4 h-4" />
|
||||
<span>{menu.label}</span>
|
||||
<ChevronDown className={clsx('w-3 h-3 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 min-w-[160px] glass-effect-solid rounded-lg py-1 z-[9999]">
|
||||
{menu.items.map((item) => (
|
||||
item.type === 'link' ? (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onItemClick?.();
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<div
|
||||
key={item.label}
|
||||
className="relative"
|
||||
onMouseEnter={() => setActiveSubmenu(item.label)}
|
||||
onMouseLeave={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-4 py-2 text-sm transition-colors cursor-pointer',
|
||||
activeSubmenu === item.label
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
{activeSubmenu === item.label && item.submenu && (
|
||||
<div className="absolute left-full top-0 ml-1 min-w-[120px] glass-effect-solid rounded-lg py-1 z-[10000]">
|
||||
{item.submenu.items.map((subItem) => (
|
||||
subItem.path ? (
|
||||
<NavLink
|
||||
key={subItem.path}
|
||||
to={subItem.path}
|
||||
onClick={() => handleSubItemClick(subItem)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<button
|
||||
key={subItem.label}
|
||||
onClick={() => handleSubItemClick(subItem)}
|
||||
className="flex items-center space-x-2 px-4 py-2 text-sm transition-colors text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일용 드롭다운 (펼쳐진 형태)
|
||||
function MobileDropdownMenu({
|
||||
menu,
|
||||
onItemClick,
|
||||
onAction
|
||||
}: {
|
||||
menu: DropdownMenuConfig;
|
||||
onItemClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
|
||||
const handleSubItemClick = (subItem: NavItem) => {
|
||||
if (subItem.action) {
|
||||
onAction?.(subItem.action);
|
||||
}
|
||||
onItemClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<menu.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{menu.label}</span>
|
||||
</div>
|
||||
<ChevronDown className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{menu.items.map((item) => (
|
||||
item.type === 'link' ? (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={onItemClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<div key={item.label}>
|
||||
<button
|
||||
onClick={() => setActiveSubmenu(activeSubmenu === item.label ? null : item.label)}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<ChevronDown className={clsx('w-3 h-3 transition-transform', activeSubmenu === item.label && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{activeSubmenu === item.label && item.submenu && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{item.submenu.items.map((subItem) => (
|
||||
subItem.path ? (
|
||||
<NavLink
|
||||
key={subItem.path}
|
||||
to={subItem.path}
|
||||
onClick={onItemClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<button
|
||||
key={subItem.label}
|
||||
onClick={() => handleSubItemClick(subItem)}
|
||||
className="flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200 text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ isConnected }: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'userInfo') {
|
||||
setShowUserInfoDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="glass-effect relative z-[9999]">
|
||||
{/* Main Header Bar */}
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
{/* Logo & Mobile Menu Button */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="lg:hidden text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<AmkorLogo height={36} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
{/* 드롭다운 메뉴들 */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||
))}
|
||||
|
||||
{/* 일반 메뉴들 */}
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
|
||||
isActive
|
||||
? 'bg-white/20 text-white shadow-lg'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right Section: Connection Status (Icon only) */}
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
isConnected ? 'bg-success-400 animate-pulse' : 'bg-danger-400'
|
||||
}`}
|
||||
title={isConnected ? '연결됨' : '연결 끊김'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Dropdown */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="lg:hidden border-t border-white/10">
|
||||
<nav className="px-4 py-2 space-y-1">
|
||||
{/* 드롭다운 메뉴들 */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
<MobileDropdownMenu
|
||||
key={menu.label}
|
||||
menu={menu}
|
||||
onItemClick={() => setIsMobileMenuOpen(false)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 일반 메뉴들 */}
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* User Info Dialog */}
|
||||
<UserInfoDialog
|
||||
isOpen={showUserInfoDialog}
|
||||
onClose={() => setShowUserInfoDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user