- TodayIssue 타입 정의 파일 생성 (src/types/today-issue.ts) - TodayIssue API 서비스 함수 생성 (src/lib/api/today-issue.ts) - getUnreadTodayIssues: 읽지 않은 알림 목록 조회 - markTodayIssueAsRead: 개별 읽음 처리 - markAllTodayIssuesAsRead: 전체 읽음 처리 - AuthenticatedLayout 알림 드롭다운 API 연동 - MOCK_NOTIFICATIONS 제거, 실제 API 연동 - 30초 폴링으로 알림 데이터 갱신 - 알림 클릭 시 읽음 처리 + 페이지 이동 - 모두 읽음 버튼 기능 구현 - 벨 애니메이션 (읽지 않은 알림 있을 때만)
985 lines
43 KiB
TypeScript
985 lines
43 KiB
TypeScript
'use client';
|
|
|
|
import { useMenuStore } from '@/store/menuStore';
|
|
import type { SerializableMenuItem } from '@/store/menuStore';
|
|
import type { MenuItem } from '@/store/menuStore';
|
|
import { useRouter, usePathname } from 'next/navigation';
|
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
import {
|
|
Menu,
|
|
Search,
|
|
User,
|
|
LogOut,
|
|
LayoutDashboard,
|
|
Sun,
|
|
Moon,
|
|
Accessibility,
|
|
ShoppingCart,
|
|
Building2,
|
|
Receipt,
|
|
Package,
|
|
Settings,
|
|
DollarSign,
|
|
ChevronLeft,
|
|
Home,
|
|
X,
|
|
BarChart3,
|
|
Award,
|
|
Bell,
|
|
Clock,
|
|
} from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import Sidebar from '@/components/layout/Sidebar';
|
|
import { useTheme } from '@/contexts/ThemeContext';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
|
import { useMenuPolling } from '@/hooks/useMenuPolling';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
|
|
// TodayIssue 타입 및 API
|
|
import type { TodayIssueUnreadItem } from '@/types/today-issue';
|
|
import {
|
|
getUnreadTodayIssues,
|
|
markTodayIssueAsRead,
|
|
markAllTodayIssuesAsRead,
|
|
} from '@/lib/api/today-issue';
|
|
|
|
// 목업 회사 데이터
|
|
const MOCK_COMPANIES = [
|
|
{ id: 'all', name: '전체' },
|
|
{ id: 'company1', name: '(주)삼성건설' },
|
|
{ id: 'company2', name: '현대건설(주)' },
|
|
{ id: 'company3', name: '대우건설(주)' },
|
|
{ id: 'company4', name: 'GS건설(주)' },
|
|
];
|
|
|
|
// 알림 폴링 간격 (30초)
|
|
const NOTIFICATION_POLLING_INTERVAL = 30000;
|
|
|
|
interface AuthenticatedLayoutProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
|
|
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
|
|
const { theme, setTheme } = useTheme();
|
|
const { logout } = useAuth();
|
|
const router = useRouter();
|
|
const pathname = usePathname(); // 현재 경로 추적
|
|
|
|
// 확장된 서브메뉴 관리 (기본적으로 sales, master-data 확장)
|
|
const [expandedMenus, setExpandedMenus] = useState<string[]>(['sales', 'master-data']);
|
|
|
|
// 모바일 상태 관리
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
|
|
|
// 사용자 정보 상태
|
|
const [userName, setUserName] = useState<string>("사용자");
|
|
const [userPosition, setUserPosition] = useState<string>("직책");
|
|
const [userEmail, setUserEmail] = useState<string>("");
|
|
const [userCompany, setUserCompany] = useState<string>("");
|
|
|
|
// 회사 선택 상태 (목업)
|
|
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
|
|
|
// 알림 관련 상태
|
|
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
|
const [notifications, setNotifications] = useState<TodayIssueUnreadItem[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
|
|
// 알림 벨 애니메이션 (알림 켜져 있고 읽지 않은 알림이 있을 때만)
|
|
const bellAnimating = useMemo(() => notificationEnabled && unreadCount > 0, [notificationEnabled, unreadCount]);
|
|
|
|
// 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용)
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
|
|
// 🔧 클라이언트 마운트 확인 (새로고침 시 스피너 표시)
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
// 알림 데이터 가져오기 함수
|
|
const fetchNotifications = useCallback(async () => {
|
|
try {
|
|
const response = await getUnreadTodayIssues(10);
|
|
if (response.success && response.data) {
|
|
setNotifications(response.data.items);
|
|
setUnreadCount(response.data.total);
|
|
}
|
|
} catch (error) {
|
|
console.error('[Notification] 알림 조회 실패:', error);
|
|
}
|
|
}, []);
|
|
|
|
// 알림 폴링 (30초마다)
|
|
useEffect(() => {
|
|
if (!isMounted) return;
|
|
|
|
// 초기 로드
|
|
fetchNotifications();
|
|
|
|
// 폴링 시작
|
|
const intervalId = setInterval(fetchNotifications, NOTIFICATION_POLLING_INTERVAL);
|
|
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
};
|
|
}, [isMounted, fetchNotifications]);
|
|
|
|
// 알림 클릭 핸들러 (읽음 처리 + 페이지 이동)
|
|
const handleNotificationClick = useCallback(async (notification: TodayIssueUnreadItem) => {
|
|
try {
|
|
// 읽음 처리
|
|
await markTodayIssueAsRead(notification.id);
|
|
|
|
// 로컬 상태 업데이트 (해당 알림 제거)
|
|
setNotifications(prev => prev.filter(n => n.id !== notification.id));
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
|
|
// 페이지 이동 (path가 있으면)
|
|
if (notification.path) {
|
|
router.push(notification.path);
|
|
}
|
|
} catch (error) {
|
|
console.error('[Notification] 읽음 처리 실패:', error);
|
|
}
|
|
}, [router]);
|
|
|
|
// 모두 읽음 처리 핸들러
|
|
const handleMarkAllAsRead = useCallback(async () => {
|
|
try {
|
|
const response = await markAllTodayIssuesAsRead();
|
|
if (response.success) {
|
|
// 로컬 상태 초기화
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
}
|
|
} catch (error) {
|
|
console.error('[Notification] 모두 읽음 처리 실패:', error);
|
|
}
|
|
}, []);
|
|
|
|
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
|
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
|
const { restartAfterAuth } = useMenuPolling({
|
|
enabled: true,
|
|
interval: 30000, // 30초
|
|
onMenuUpdated: () => {
|
|
console.log('[Menu] 메뉴가 업데이트되었습니다');
|
|
},
|
|
onSessionExpired: () => {
|
|
console.log('[Menu] 세션 만료로 폴링 중지됨');
|
|
},
|
|
});
|
|
|
|
// 로그인 성공 후 메뉴 폴링 재시작
|
|
useEffect(() => {
|
|
const justLoggedIn = sessionStorage.getItem('auth_just_logged_in');
|
|
if (justLoggedIn === 'true') {
|
|
console.log('[Menu] 로그인 감지 - 폴링 재시작');
|
|
sessionStorage.removeItem('auth_just_logged_in');
|
|
restartAfterAuth();
|
|
}
|
|
}, [restartAfterAuth]);
|
|
|
|
// 모바일 감지 + 폴더블 기기 대응 (Galaxy Fold 등)
|
|
// 🔧 Debounce 적용: resize 시 API 중복 호출 방지
|
|
// - isMobile 상태 변경 시 전체 자식 컴포넌트가 리마운트됨
|
|
// - debounce 없이 빠른 resize 시 API가 여러 번 호출되는 문제 해결
|
|
useEffect(() => {
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const updateViewport = () => {
|
|
// visualViewport API 우선 사용 (폴더블 기기에서 더 정확)
|
|
const width = window.visualViewport?.width ?? window.innerWidth;
|
|
const height = window.visualViewport?.height ?? window.innerHeight;
|
|
|
|
// CSS 변수는 즉시 업데이트 (레이아웃 깨짐 방지)
|
|
document.documentElement.style.setProperty('--app-width', `${width}px`);
|
|
document.documentElement.style.setProperty('--app-height', `${height}px`);
|
|
|
|
// isMobile 상태 변경은 debounce 적용 (컴포넌트 리마운트 최소화)
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
debounceTimer = setTimeout(() => {
|
|
setIsMobile(width < 768);
|
|
}, 300); // 300ms debounce - 데이터 로딩 안정성 개선
|
|
};
|
|
|
|
// 초기 값 설정 (debounce 없이 즉시)
|
|
const width = window.visualViewport?.width ?? window.innerWidth;
|
|
const height = window.visualViewport?.height ?? window.innerHeight;
|
|
setIsMobile(width < 768);
|
|
document.documentElement.style.setProperty('--app-width', `${width}px`);
|
|
document.documentElement.style.setProperty('--app-height', `${height}px`);
|
|
|
|
// resize 이벤트
|
|
window.addEventListener('resize', updateViewport);
|
|
|
|
// visualViewport resize 이벤트 (폴더블 기기 화면 전환 감지)
|
|
window.visualViewport?.addEventListener('resize', updateViewport);
|
|
|
|
return () => {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
window.removeEventListener('resize', updateViewport);
|
|
window.visualViewport?.removeEventListener('resize', updateViewport);
|
|
};
|
|
}, []);
|
|
|
|
// 서버에서 받은 사용자 정보로 초기화
|
|
useEffect(() => {
|
|
// ⚠️ Allow rendering even before hydration (Zustand persist rehydration can be slow)
|
|
// Commenting out the hydration check prevents infinite loading spinner
|
|
// if (!_hasHydrated) return;
|
|
|
|
// localStorage에서 사용자 정보 가져오기
|
|
const userDataStr = localStorage.getItem("user");
|
|
if (userDataStr) {
|
|
const userData = JSON.parse(userDataStr);
|
|
|
|
// 사용자 이름, 직책, 이메일, 회사 설정
|
|
setUserName(userData.name || "사용자");
|
|
setUserPosition(userData.position || "직책");
|
|
setUserEmail(userData.email || "");
|
|
setUserCompany(userData.company || userData.company_name || "");
|
|
|
|
// 서버에서 받은 메뉴 배열이 있으면 사용, 없으면 기본 메뉴 사용
|
|
if (userData.menu && Array.isArray(userData.menu) && userData.menu.length > 0) {
|
|
// SerializableMenuItem (iconName string)을 MenuItem (icon component)로 변환
|
|
const deserializedMenus = deserializeMenuItems(userData.menu as SerializableMenuItem[]);
|
|
setMenuItems(deserializedMenus);
|
|
} else {
|
|
// API가 준비될 때까지 임시 기본 메뉴
|
|
const defaultMenu: MenuItem[] = [
|
|
{ id: "dashboard", label: "대시보드", icon: LayoutDashboard, path: "/dashboard" },
|
|
{
|
|
id: "sales",
|
|
label: "판매관리",
|
|
icon: ShoppingCart,
|
|
path: "#",
|
|
children: [
|
|
{ id: "customer-management", label: "거래처관리", icon: Building2, path: "/sales/client-management-sales-admin" },
|
|
{ id: "quote-management", label: "견적관리", icon: Receipt, path: "/sales/quote-management" },
|
|
{ id: "pricing-management", label: "단가관리", icon: DollarSign, path: "/sales/pricing-management" },
|
|
],
|
|
},
|
|
{
|
|
id: "master-data",
|
|
label: "기준정보",
|
|
icon: Settings,
|
|
path: "#",
|
|
children: [
|
|
{ id: "item-master", label: "품목기준관리", icon: Package, path: "/master-data/item-master-data-management" },
|
|
],
|
|
},
|
|
];
|
|
setMenuItems(defaultMenu);
|
|
}
|
|
}
|
|
}, [_hasHydrated, setMenuItems]);
|
|
|
|
// 현재 경로에 맞는 메뉴 자동 활성화 (URL 직접 입력, 뒤로가기 대응)
|
|
// 3depth 이상 메뉴 구조 지원
|
|
useEffect(() => {
|
|
if (!pathname || menuItems.length === 0) return;
|
|
|
|
// 경로 정규화 (로케일 제거)
|
|
const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
|
|
|
|
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
|
// 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭
|
|
const isPathMatch = (menuPath: string, currentPath: string): boolean => {
|
|
if (currentPath === menuPath) return true;
|
|
return currentPath.startsWith(menuPath + '/');
|
|
};
|
|
|
|
// 재귀적으로 모든 depth의 메뉴를 탐색 (3depth 이상 지원)
|
|
type MenuMatch = { menuId: string; ancestorIds: string[]; pathLength: number };
|
|
|
|
const findMenuRecursive = (
|
|
items: MenuItem[],
|
|
currentPath: string,
|
|
ancestors: string[] = []
|
|
): MenuMatch[] => {
|
|
const matches: MenuMatch[] = [];
|
|
|
|
for (const item of items) {
|
|
// 현재 메뉴의 경로 매칭 확인
|
|
if (item.path && item.path !== '#' && isPathMatch(item.path, currentPath)) {
|
|
matches.push({
|
|
menuId: item.id,
|
|
ancestorIds: ancestors,
|
|
pathLength: item.path.length,
|
|
});
|
|
}
|
|
|
|
// 자식 메뉴가 있으면 재귀적으로 탐색
|
|
if (item.children && item.children.length > 0) {
|
|
const childMatches = findMenuRecursive(
|
|
item.children,
|
|
currentPath,
|
|
[...ancestors, item.id] // 현재 메뉴를 조상 목록에 추가
|
|
);
|
|
matches.push(...childMatches);
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
};
|
|
|
|
const matches = findMenuRecursive(menuItems, normalizedPath);
|
|
|
|
if (matches.length > 0) {
|
|
// 가장 긴 경로(가장 구체적인 매칭) 선택
|
|
matches.sort((a, b) => b.pathLength - a.pathLength);
|
|
const result = matches[0];
|
|
|
|
// 활성 메뉴 설정
|
|
setActiveMenu(result.menuId);
|
|
|
|
// 모든 조상 메뉴를 확장 (3depth 이상 지원)
|
|
if (result.ancestorIds.length > 0) {
|
|
setExpandedMenus(prev => {
|
|
const newExpanded = [...prev];
|
|
result.ancestorIds.forEach(id => {
|
|
if (!newExpanded.includes(id)) {
|
|
newExpanded.push(id);
|
|
}
|
|
});
|
|
return newExpanded;
|
|
});
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pathname, menuItems, setActiveMenu]);
|
|
|
|
const handleMenuClick = (menuId: string, path: string) => {
|
|
// 네비게이션 우선 실행 - setActiveMenu는 pathname 기반 useEffect가 처리
|
|
router.push(path);
|
|
};
|
|
|
|
// 서브메뉴 토글 함수
|
|
const toggleSubmenu = (menuId: string) => {
|
|
setExpandedMenus(prev =>
|
|
prev.includes(menuId)
|
|
? prev.filter(id => id !== menuId)
|
|
: [...prev, menuId]
|
|
);
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
// AuthContext의 logout() 호출 (완전한 캐시 정리 수행)
|
|
// - Zustand 스토어 초기화
|
|
// - sessionStorage 캐시 삭제 (page_config_*, mes-*)
|
|
// - localStorage 사용자 데이터 삭제
|
|
// - 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
|
await logout();
|
|
|
|
// 로그인 페이지로 리다이렉트
|
|
router.push('/login');
|
|
} catch (error) {
|
|
console.error('로그아웃 처리 중 오류:', error);
|
|
// 에러가 나도 로그인 페이지로 이동
|
|
router.push('/login');
|
|
}
|
|
};
|
|
|
|
// ⚠️ FIXED: Removed hydration check to prevent infinite loading spinner
|
|
// The hydration check was causing the dashboard to show a loading spinner indefinitely
|
|
// because Zustand persist rehydration was taking too long or not completing properly.
|
|
// By removing this check, we allow the component to render immediately with default values
|
|
// and update once hydration completes through the useEffect above.
|
|
|
|
// 🔧 새로고침 시 스피너 표시 (hydration 전)
|
|
if (!isMounted) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
<div className="text-center">
|
|
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
|
<p className="text-muted-foreground text-sm">로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 현재 페이지가 대시보드인지 확인
|
|
const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard';
|
|
|
|
// 이전 페이지로 이동
|
|
const handleGoBack = () => {
|
|
router.back();
|
|
};
|
|
|
|
// 홈(대시보드)으로 이동
|
|
const handleGoHome = () => {
|
|
router.push('/dashboard');
|
|
};
|
|
|
|
// 모바일 레이아웃
|
|
if (isMobile) {
|
|
return (
|
|
<div className="flex flex-col bg-background min-h-screen" style={{ height: 'var(--app-height)' }}>
|
|
{/* 모바일 헤더 - sam-design 스타일 */}
|
|
<header className="clean-glass sticky top-0 z-40 px-1.5 py-1.5 m-1.5 min-[320px]:px-2 min-[320px]:py-2 min-[320px]:m-2 sm:px-4 sm:py-4 sm:m-3 rounded-2xl clean-shadow">
|
|
<div className="flex items-center justify-between">
|
|
{/* 좌측 영역: 햄버거 메뉴 + (대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼) */}
|
|
<div className="flex items-center space-x-1 min-[320px]:space-x-2">
|
|
{/* 햄버거 메뉴 - 좌측 맨 앞 */}
|
|
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" 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-xl hover:bg-accent transition-all duration-200 flex items-center justify-center">
|
|
<Menu className="h-4 w-4 min-[320px]:h-5 min-[320px]:w-5 sm:h-6 sm:w-6" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="w-[calc(100vw-24px)] min-[320px]:w-80 p-1 bg-transparent border-none">
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>메뉴</SheetTitle>
|
|
</SheetHeader>
|
|
<Sidebar
|
|
menuItems={menuItems}
|
|
activeMenu={activeMenu}
|
|
expandedMenus={expandedMenus}
|
|
sidebarCollapsed={false}
|
|
isMobile={true}
|
|
onMenuClick={handleMenuClick}
|
|
onToggleSubmenu={toggleSubmenu}
|
|
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
|
/>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{isDashboard ? (
|
|
// 대시보드: 로고만 표시
|
|
<div
|
|
className="flex items-center space-x-1 min-[320px]:space-x-2 sm:space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={handleGoHome}
|
|
title="대시보드로 이동"
|
|
>
|
|
<div className="w-6 h-6 min-w-6 min-h-6 min-[320px]:w-8 min-[320px]:h-8 min-[320px]:min-w-8 min-[320px]:min-h-8 sm:w-10 sm:h-10 sm:min-w-10 sm:min-h-10 flex-shrink-0 aspect-square rounded-lg min-[320px]:rounded-xl flex items-center justify-center shadow-md relative overflow-hidden bg-gradient-to-br from-blue-500 to-blue-600">
|
|
<div className="text-white font-bold text-sm min-[320px]:text-base sm:text-lg">S</div>
|
|
</div>
|
|
<div>
|
|
<h1 className="font-bold text-foreground text-left text-xs min-[320px]:text-sm sm:text-base">SAM</h1>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// 다른 페이지: 이전/홈 버튼 표시
|
|
<div className="flex items-center space-x-0.5 sm:space-x-1">
|
|
<Button
|
|
variant="ghost"
|
|
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-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
|
|
onClick={handleGoBack}
|
|
title="이전 페이지"
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
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-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
|
|
onClick={handleGoHome}
|
|
title="홈으로"
|
|
>
|
|
<Home className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측 영역: 종합분석, 품질인정심사, 유저 드롭다운, 메뉴 */}
|
|
<div className="flex items-center space-x-0.5 sm:space-x-1">
|
|
{/* 종합분석 바로가기 */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => router.push('/reports/comprehensive-analysis')}
|
|
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="종합분석"
|
|
>
|
|
<BarChart3 className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
|
</Button>
|
|
|
|
{/* 품질인정심사 바로가기 */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => router.push('/quality/qms')}
|
|
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-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center"
|
|
title="품질인정심사"
|
|
>
|
|
<Award className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
|
</Button>
|
|
|
|
{/* 알림 버튼 - 드롭다운 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
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 hover:bg-accent transition-all duration-200 flex items-center justify-center relative"
|
|
>
|
|
<Bell className={`h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-amber-600 ${bellAnimating ? 'animate-bell-ring' : ''}`} />
|
|
{/* 새 알림 표시 */}
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-1 right-1 min-[320px]:top-1.5 min-[320px]:right-1.5 sm:top-2 sm:right-2 w-1 h-1 min-[320px]:w-1.5 min-[320px]:h-1.5 sm:w-2 sm:h-2 bg-red-500 rounded-full" />
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
|
{/* 헤더: 알림 토글 + 모두 읽음 */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-foreground">알림</span>
|
|
{unreadCount > 0 && (
|
|
<span className="text-xs text-muted-foreground">({unreadCount})</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{notifications.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleMarkAllAsRead();
|
|
}}
|
|
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700"
|
|
>
|
|
모두 읽음
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setNotificationEnabled(!notificationEnabled);
|
|
}}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
{notificationEnabled ? '🔔' : '🔕'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/* 알림 리스트 - 3개 초과시 스크롤 */}
|
|
<div className="max-h-[216px] overflow-y-auto">
|
|
{notifications.length === 0 ? (
|
|
<div className="p-6 text-center text-muted-foreground text-sm">
|
|
새로운 알림이 없습니다
|
|
</div>
|
|
) : (
|
|
notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
|
>
|
|
{/* 배지 */}
|
|
<div className="flex-shrink-0">
|
|
<span className={`inline-flex items-center justify-center px-2 py-1 text-xs font-medium rounded-md ${
|
|
notification.needs_approval
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-blue-100 text-blue-700'
|
|
}`}>
|
|
{notification.badge}
|
|
</span>
|
|
</div>
|
|
{/* 내용 */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-foreground font-medium text-sm truncate">{notification.content}</p>
|
|
<span className="text-muted-foreground text-xs">{notification.time}</span>
|
|
</div>
|
|
{/* 승인 필요 표시 */}
|
|
{notification.needs_approval && (
|
|
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
|
!
|
|
</span>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* 유저 프로필 드롭다운 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
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 hover:bg-accent transition-all duration-200 flex items-center justify-center"
|
|
>
|
|
<div className="w-6 h-6 min-[320px]:w-8 min-[320px]:h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
|
<User className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
</div>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64 p-0 overflow-hidden">
|
|
{/* 사용자 정보 헤더 */}
|
|
<div className="bg-muted px-4 py-4 flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<User className="h-6 w-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-foreground">{userName}</p>
|
|
<p className="text-sm text-muted-foreground">{userPosition}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 출근하기 버튼 */}
|
|
<div className="px-4 py-3 border-b border-border">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => router.push('/hr/attendance')}
|
|
className="w-full h-10 bg-blue-500 hover:bg-blue-600 text-white rounded-lg flex items-center justify-center gap-2"
|
|
>
|
|
<Clock className="h-4 w-4" />
|
|
출근하기
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 회사 선택 (목업) */}
|
|
<div className="px-4 py-3 border-b border-border">
|
|
<p className="text-xs text-muted-foreground mb-1">회사 선택</p>
|
|
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
|
<SelectTrigger className="w-full h-9 text-sm">
|
|
<SelectValue placeholder="회사 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MOCK_COMPANIES.map((company) => (
|
|
<SelectItem key={company.id} value={company.id}>
|
|
{company.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 테마 선택 */}
|
|
<div className="px-2 py-3">
|
|
<DropdownMenuItem onClick={() => setTheme('light')} className="rounded-lg">
|
|
<Sun className="mr-2 h-4 w-4" />
|
|
일반모드
|
|
{theme === 'light' && <span className="ml-auto text-primary">✓</span>}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setTheme('dark')} className="rounded-lg">
|
|
<Moon className="mr-2 h-4 w-4" />
|
|
다크모드
|
|
{theme === 'dark' && <span className="ml-auto text-primary">✓</span>}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setTheme('senior')} className="rounded-lg">
|
|
<Accessibility className="mr-2 h-4 w-4" />
|
|
시니어모드
|
|
{theme === 'senior' && <span className="ml-auto text-primary">✓</span>}
|
|
</DropdownMenuItem>
|
|
|
|
{/* 구분선 */}
|
|
<div className="my-2 border-t border-border" />
|
|
|
|
{/* 로그아웃 */}
|
|
<DropdownMenuItem onClick={handleLogout} className="rounded-lg text-red-600 hover:text-red-700 hover:bg-red-50">
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
로그아웃
|
|
</DropdownMenuItem>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 모바일 콘텐츠 */}
|
|
<main className="flex-1 overflow-y-auto px-3 overscroll-contain" style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y pinch-zoom' }}>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 데스크톱 레이아웃
|
|
return (
|
|
<div className="min-h-screen flex flex-col w-full">
|
|
{/* 헤더 - 전체 너비 상단 고정 */}
|
|
<header className="clean-glass px-8 py-5 mx-3 mt-3 mb-0 rounded-2xl clean-shadow relative overflow-hidden flex-shrink-0 sticky top-3 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<div className="flex items-center justify-between relative z-10">
|
|
<div className="flex items-center space-x-6">
|
|
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
|
|
<div
|
|
className="flex items-center space-x-4 pr-6 border-r border-border/30 cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={handleGoHome}
|
|
title="대시보드로 이동"
|
|
>
|
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center shadow-md relative overflow-hidden bg-gradient-to-br from-blue-500 to-blue-600 flex-shrink-0">
|
|
<div className="text-white font-bold text-xl">S</div>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-foreground">SAM</h1>
|
|
<p className="text-xs text-muted-foreground font-medium">Smart Automation Management</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Menu 버튼 - 사이드바 토글 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleSidebar}
|
|
className="rounded-xl transition-all duration-200 hover:bg-accent p-3"
|
|
>
|
|
<Menu className="h-5 w-5" />
|
|
</Button>
|
|
|
|
{/* 검색바 */}
|
|
<div className="relative hidden lg:block">
|
|
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-muted-foreground h-5 w-5" />
|
|
<Input
|
|
placeholder="통합 검색..."
|
|
className="pl-14 w-96 clean-input border-0 bg-input-background/60 text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
{/* 종합분석 바로가기 버튼 */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => router.push('/reports/comprehensive-analysis')}
|
|
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"
|
|
>
|
|
<BarChart3 className="h-4 w-4" />
|
|
<span className="hidden xl:inline">종합분석</span>
|
|
</Button>
|
|
|
|
{/* 품질인정심사 바로가기 버튼 */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => router.push('/quality/qms')}
|
|
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
|
>
|
|
<Award className="h-4 w-4" />
|
|
<span className="hidden xl:inline">품질인정심사</span>
|
|
</Button>
|
|
|
|
{/* 알림 버튼 - 드롭다운 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="p-1 rounded-xl hover:bg-transparent transition-all duration-200 relative focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
>
|
|
<div className="w-14 h-14 bg-amber-50 rounded-full flex items-center justify-center">
|
|
<Bell className={`text-amber-500 ${bellAnimating ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
|
|
</div>
|
|
{/* 새 알림 표시 */}
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-0 right-0 w-3.5 h-3.5 bg-red-500 rounded-full border-2 border-background" />
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
|
{/* 헤더: 알림 토글 + 모두 읽음 */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-foreground">알림</span>
|
|
{unreadCount > 0 && (
|
|
<span className="text-xs text-muted-foreground">({unreadCount})</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{notifications.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleMarkAllAsRead();
|
|
}}
|
|
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700"
|
|
>
|
|
모두 읽음
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setNotificationEnabled(!notificationEnabled);
|
|
}}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
{notificationEnabled ? '🔔' : '🔕'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/* 알림 리스트 - 3개 초과시 스크롤 */}
|
|
<div className="max-h-[216px] overflow-y-auto">
|
|
{notifications.length === 0 ? (
|
|
<div className="p-6 text-center text-muted-foreground text-sm">
|
|
새로운 알림이 없습니다
|
|
</div>
|
|
) : (
|
|
notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
|
>
|
|
{/* 배지 */}
|
|
<div className="flex-shrink-0">
|
|
<span className={`inline-flex items-center justify-center px-2 py-1 text-xs font-medium rounded-md ${
|
|
notification.needs_approval
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-blue-100 text-blue-700'
|
|
}`}>
|
|
{notification.badge}
|
|
</span>
|
|
</div>
|
|
{/* 내용 */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-foreground font-medium text-sm truncate">{notification.content}</p>
|
|
<span className="text-muted-foreground text-xs">{notification.time}</span>
|
|
</div>
|
|
{/* 승인 필요 표시 */}
|
|
{notification.needs_approval && (
|
|
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
|
!
|
|
</span>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* 유저 프로필 드롭다운 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="hidden lg:flex items-center space-x-3 pl-3 border-l border-border/30 h-auto py-2 px-3 rounded-xl hover:bg-accent transition-all duration-200">
|
|
<div className="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center">
|
|
<User className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div className="text-sm text-left">
|
|
<p className="font-bold text-foreground text-base">{userName}</p>
|
|
<p className="text-muted-foreground text-sm">{userPosition}</p>
|
|
</div>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64 p-0 overflow-hidden">
|
|
{/* 사용자 정보 헤더 */}
|
|
<div className="bg-muted px-4 py-4 flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<User className="h-6 w-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-foreground">{userName}</p>
|
|
<p className="text-sm text-muted-foreground">{userPosition}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 회사 선택 (목업) */}
|
|
<div className="px-4 py-3 border-b border-border">
|
|
<p className="text-xs text-muted-foreground mb-1">회사 선택</p>
|
|
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
|
<SelectTrigger className="w-full h-9 text-sm">
|
|
<SelectValue placeholder="회사 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MOCK_COMPANIES.map((company) => (
|
|
<SelectItem key={company.id} value={company.id}>
|
|
{company.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 테마 선택 */}
|
|
<div className="px-2 py-3">
|
|
<DropdownMenuItem onClick={() => setTheme('light')} className="rounded-lg">
|
|
<Sun className="mr-2 h-4 w-4" />
|
|
일반모드
|
|
{theme === 'light' && <span className="ml-auto text-primary">✓</span>}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setTheme('dark')} className="rounded-lg">
|
|
<Moon className="mr-2 h-4 w-4" />
|
|
다크모드
|
|
{theme === 'dark' && <span className="ml-auto text-primary">✓</span>}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setTheme('senior')} className="rounded-lg">
|
|
<Accessibility className="mr-2 h-4 w-4" />
|
|
시니어모드
|
|
{theme === 'senior' && <span className="ml-auto text-primary">✓</span>}
|
|
</DropdownMenuItem>
|
|
|
|
{/* 구분선 */}
|
|
<div className="my-2 border-t border-border" />
|
|
|
|
{/* 로그아웃 */}
|
|
<DropdownMenuItem onClick={handleLogout} className="rounded-lg text-red-600 hover:text-red-700 hover:bg-red-50">
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
로그아웃
|
|
</DropdownMenuItem>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subtle gradient overlay */}
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
|
</header>
|
|
|
|
{/* 사이드바 + 메인 콘텐츠 영역 */}
|
|
<div className="flex flex-1 gap-3 px-3 pb-3">
|
|
{/* 데스크톱 사이드바 */}
|
|
<div
|
|
className={`sticky top-[106px] self-start h-[calc(100vh-118px)] mt-3 border-none bg-transparent transition-all duration-300 flex-shrink-0 ${
|
|
sidebarCollapsed ? 'w-24' : 'w-64'
|
|
}`}
|
|
>
|
|
<Sidebar
|
|
menuItems={menuItems}
|
|
activeMenu={activeMenu}
|
|
expandedMenus={expandedMenus}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
isMobile={false}
|
|
onMenuClick={handleMenuClick}
|
|
onToggleSubmenu={toggleSubmenu}
|
|
/>
|
|
</div>
|
|
|
|
{/* 메인 콘텐츠 */}
|
|
<main className="flex-1 overflow-auto p-3 md:p-6 pb-0">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |