feat(WEB): 헤더 알림 드롭다운 TodayIssue API 연동

- TodayIssue 타입 정의 파일 생성 (src/types/today-issue.ts)
- TodayIssue API 서비스 함수 생성 (src/lib/api/today-issue.ts)
  - getUnreadTodayIssues: 읽지 않은 알림 목록 조회
  - markTodayIssueAsRead: 개별 읽음 처리
  - markAllTodayIssuesAsRead: 전체 읽음 처리
- AuthenticatedLayout 알림 드롭다운 API 연동
  - MOCK_NOTIFICATIONS 제거, 실제 API 연동
  - 30초 폴링으로 알림 데이터 갱신
  - 알림 클릭 시 읽음 처리 + 페이지 이동
  - 모두 읽음 버튼 기능 구현
  - 벨 애니메이션 (읽지 않은 알림 있을 때만)
This commit is contained in:
2026-01-21 21:14:56 +09:00
parent f2b87ddf0a
commit 81a4d6baf1
3 changed files with 380 additions and 85 deletions

View File

@@ -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<string>("all");
// 알림 벨 애니메이션 상태 (클릭으로 토글)
const [bellAnimating, setBellAnimating] = useState(false);
// 알림 관련 상태
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);
@@ -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
>
<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' : ''}`} />
{/* 새 알림 표시 */}
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
{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">
<span className="font-semibold text-foreground"></span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
setBellAnimating(!bellAnimating);
}}
className="h-7 px-2 text-xs"
>
{bellAnimating ? '🔔 알림 끄기' : '🔕 알림 켜기'}
</Button>
<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">
{MOCK_NOTIFICATIONS.map((notification) => (
<div
key={notification.id}
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
>
{/* 이미지 플레이스홀더 */}
<div className="flex-shrink-0 w-12 h-12 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
IMG
</div>
{/* 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs">{notification.category}</span>
{notification.isNew && (
<span className="w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
N
</span>
)}
</div>
<p className="text-foreground font-medium text-sm mt-1 truncate">{notification.title}</p>
</div>
{/* 날짜 */}
<span className="flex-shrink-0 text-muted-foreground text-xs">{notification.date}</span>
{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>
@@ -696,54 +796,84 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
<Bell className={`text-amber-500 ${bellAnimating ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
</div>
{/* 새 알림 표시 */}
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
{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">
<span className="font-semibold text-foreground"></span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
setBellAnimating(!bellAnimating);
}}
className="h-7 px-2 text-xs"
>
{bellAnimating ? '🔔 알림 끄기' : '🔕 알림 켜기'}
</Button>
<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">
{MOCK_NOTIFICATIONS.map((notification) => (
<div
key={notification.id}
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
>
{/* 이미지 플레이스홀더 */}
<div className="flex-shrink-0 w-12 h-12 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
IMG
</div>
{/* 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs">{notification.category}</span>
{notification.isNew && (
<span className="w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
N
</span>
)}
</div>
<p className="text-foreground font-medium text-sm mt-1 truncate">{notification.title}</p>
</div>
{/* 날짜 */}
<span className="flex-shrink-0 text-muted-foreground text-xs">{notification.date}</span>
{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>

View File

@@ -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<ApiResponse<TodayIssueUnreadResponse>> {
try {
const response = await apiClient.get<ApiResponse<TodayIssueUnreadResponse>>(
'/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<ApiResponse<TodayIssueUnreadCountResponse>> {
try {
const response = await apiClient.get<ApiResponse<TodayIssueUnreadCountResponse>>(
'/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<ApiResponse<null>> {
try {
const response = await apiClient.post<ApiResponse<null>>(
`/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<ApiResponse<TodayIssueMarkAllReadResponse>> {
try {
const response = await apiClient.post<ApiResponse<TodayIssueMarkAllReadResponse>>(
'/today-issues/read-all'
);
return response;
} catch (error) {
console.error('[TodayIssue] markAllTodayIssuesAsRead error:', error);
return {
success: false,
message: '모두 읽음 처리에 실패했습니다.',
data: { count: 0 },
};
}
}

71
src/types/today-issue.ts Normal file
View File

@@ -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<T> {
success: boolean;
message: string;
data: T;
}