diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 5fd33ae0..29d8623f 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -4,7 +4,7 @@ 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 } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { Menu, Search, @@ -50,6 +50,14 @@ import { 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: '전체' }, @@ -59,12 +67,8 @@ const MOCK_COMPANIES = [ { id: 'company4', name: 'GS건설(주)' }, ]; -// 목업 알림 데이터 -const MOCK_NOTIFICATIONS = [ - { id: 1, category: '안내', title: '시스템 점검 안내', date: '2025.09.03 12:23', isNew: true }, - { id: 2, category: '공지사항', title: '신규 기능 업데이트', date: '2025.09.03 12:23', isNew: false }, - { id: 3, category: '안내', title: '보안 업데이트 완료', date: '2025.09.03 12:23', isNew: false }, -]; +// 알림 폴링 간격 (30초) +const NOTIFICATION_POLLING_INTERVAL = 30000; interface AuthenticatedLayoutProps { children: React.ReactNode; @@ -93,8 +97,13 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro // 회사 선택 상태 (목업) const [selectedCompany, setSelectedCompany] = useState("all"); - // 알림 벨 애니메이션 상태 (클릭으로 토글) - const [bellAnimating, setBellAnimating] = useState(false); + // 알림 관련 상태 + const [notificationEnabled, setNotificationEnabled] = useState(true); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + + // 알림 벨 애니메이션 (알림 켜져 있고 읽지 않은 알림이 있을 때만) + const bellAnimating = useMemo(() => notificationEnabled && unreadCount > 0, [notificationEnabled, unreadCount]); // 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용) const [isMounted, setIsMounted] = useState(false); @@ -104,6 +113,67 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro 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({ @@ -470,54 +540,84 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro > {/* 새 알림 표시 */} - {MOCK_NOTIFICATIONS.some(n => n.isNew) && ( + {unreadCount > 0 && ( )} - {/* 헤더: 알림 + 애니메이션 토글 */} + {/* 헤더: 알림 토글 + 모두 읽음 */}
- 알림 - +
+ 알림 + {unreadCount > 0 && ( + ({unreadCount}) + )} +
+
+ {notifications.length > 0 && ( + + )} + +
{/* 알림 리스트 - 3개 초과시 스크롤 */}
- {MOCK_NOTIFICATIONS.map((notification) => ( -
- {/* 이미지 플레이스홀더 */} -
- IMG -
- {/* 내용 */} -
-
- {notification.category} - {notification.isNew && ( - - N - - )} -
-

{notification.title}

-
- {/* 날짜 */} - {notification.date} + {notifications.length === 0 ? ( +
+ 새로운 알림이 없습니다
- ))} + ) : ( + notifications.map((notification) => ( +
handleNotificationClick(notification)} + className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer" + > + {/* 배지 */} +
+ + {notification.badge} + +
+ {/* 내용 */} +
+

{notification.content}

+ {notification.time} +
+ {/* 승인 필요 표시 */} + {notification.needs_approval && ( + + ! + + )} +
+ )) + )}
@@ -696,54 +796,84 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
{/* 새 알림 표시 */} - {MOCK_NOTIFICATIONS.some(n => n.isNew) && ( + {unreadCount > 0 && ( )} - {/* 헤더: 알림 + 애니메이션 토글 */} + {/* 헤더: 알림 토글 + 모두 읽음 */}
- 알림 - +
+ 알림 + {unreadCount > 0 && ( + ({unreadCount}) + )} +
+
+ {notifications.length > 0 && ( + + )} + +
{/* 알림 리스트 - 3개 초과시 스크롤 */}
- {MOCK_NOTIFICATIONS.map((notification) => ( -
- {/* 이미지 플레이스홀더 */} -
- IMG -
- {/* 내용 */} -
-
- {notification.category} - {notification.isNew && ( - - N - - )} -
-

{notification.title}

-
- {/* 날짜 */} - {notification.date} + {notifications.length === 0 ? ( +
+ 새로운 알림이 없습니다
- ))} + ) : ( + notifications.map((notification) => ( +
handleNotificationClick(notification)} + className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer" + > + {/* 배지 */} +
+ + {notification.badge} + +
+ {/* 내용 */} +
+

{notification.content}

+ {notification.time} +
+ {/* 승인 필요 표시 */} + {notification.needs_approval && ( + + ! + + )} +
+ )) + )}
diff --git a/src/lib/api/today-issue.ts b/src/lib/api/today-issue.ts new file mode 100644 index 00000000..d6e781dd --- /dev/null +++ b/src/lib/api/today-issue.ts @@ -0,0 +1,94 @@ +'use server'; + +/** + * TodayIssue API 서비스 + * 헤더 알림 기능을 위한 API 호출 함수 + */ + +import { apiClient } from './index'; +import type { + ApiResponse, + TodayIssueUnreadResponse, + TodayIssueUnreadCountResponse, + TodayIssueMarkAllReadResponse, +} from '@/types/today-issue'; + +/** + * 읽지 않은 이슈 목록 조회 (헤더 알림용) + * @param limit 조회할 최대 항목 수 (기본 10) + */ +export async function getUnreadTodayIssues(limit: number = 10): Promise> { + try { + const response = await apiClient.get>( + '/today-issues/unread', + { params: { limit: String(limit) } } + ); + return response; + } catch (error) { + console.error('[TodayIssue] getUnreadTodayIssues error:', error); + // 에러 시 빈 응답 반환 (UI에서 처리) + return { + success: false, + message: '알림을 불러오는데 실패했습니다.', + data: { items: [], total: 0 }, + }; + } +} + +/** + * 읽지 않은 이슈 개수 조회 (헤더 뱃지용) + */ +export async function getUnreadTodayIssueCount(): Promise> { + try { + const response = await apiClient.get>( + '/today-issues/unread/count' + ); + return response; + } catch (error) { + console.error('[TodayIssue] getUnreadTodayIssueCount error:', error); + return { + success: false, + message: '알림 개수를 불러오는데 실패했습니다.', + data: { count: 0 }, + }; + } +} + +/** + * 이슈 읽음 처리 + * @param id 이슈 ID + */ +export async function markTodayIssueAsRead(id: number): Promise> { + try { + const response = await apiClient.post>( + `/today-issues/${id}/read` + ); + return response; + } catch (error) { + console.error('[TodayIssue] markTodayIssueAsRead error:', error); + return { + success: false, + message: '읽음 처리에 실패했습니다.', + data: null, + }; + } +} + +/** + * 모든 이슈 읽음 처리 + */ +export async function markAllTodayIssuesAsRead(): Promise> { + try { + const response = await apiClient.post>( + '/today-issues/read-all' + ); + return response; + } catch (error) { + console.error('[TodayIssue] markAllTodayIssuesAsRead error:', error); + return { + success: false, + message: '모두 읽음 처리에 실패했습니다.', + data: { count: 0 }, + }; + } +} \ No newline at end of file diff --git a/src/types/today-issue.ts b/src/types/today-issue.ts new file mode 100644 index 00000000..925b1184 --- /dev/null +++ b/src/types/today-issue.ts @@ -0,0 +1,71 @@ +/** + * TodayIssue 관련 타입 정의 + * API: api/app/Swagger/v1/TodayIssueApi.php 참조 + */ + +/** 알림 뱃지 타입 (헤더 알림용) */ +export type TodayIssueBadge = + | '수주등록' + | '추심이슈' + | '안전재고' + | '지출 승인대기' + | '세금 신고' + | '결재 요청' + | '신규거래처'; + +/** 알림 설정 타입 */ +export type NotificationType = + | 'sales_order' // 수주등록 + | 'new_vendor' // 신규거래처 + | 'approval_request' // 결재 요청 + | 'bad_debt' // 추심이슈 + | 'safety_stock' // 안전재고 + | 'expected_expense' // 지출 승인대기 + | 'vat_report'; // 세금 신고 + +/** + * 읽지 않은 이슈 항목 (헤더 알림용) + * TodayIssueUnreadItem 스키마 + */ +export interface TodayIssueUnreadItem { + id: number; + badge: TodayIssueBadge; + notification_type: NotificationType; + content: string; + path: string | null; + needs_approval: boolean; + time: string; + created_at: string; // ISO 8601 +} + +/** + * 읽지 않은 이슈 목록 응답 + * TodayIssueUnreadResponse 스키마 + */ +export interface TodayIssueUnreadResponse { + items: TodayIssueUnreadItem[]; + total: number; +} + +/** + * 읽지 않은 이슈 개수 응답 + * TodayIssueUnreadCountResponse 스키마 + */ +export interface TodayIssueUnreadCountResponse { + count: number; +} + +/** + * 모든 이슈 읽음 처리 응답 + * TodayIssueMarkAllReadResponse 스키마 + */ +export interface TodayIssueMarkAllReadResponse { + count: number; +} + +/** API 응답 공통 래퍼 */ +export interface ApiResponse { + success: boolean; + message: string; + data: T; +} \ No newline at end of file